-
-
Notifications
You must be signed in to change notification settings - Fork 925
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
Cannot rescue from LocalJumpError after wrongly using break in proc #4686
Comments
A similar or nearly-identical case was found in ruby/spec and reported by @eregon in #4577 but we had not fixed it because of the lack of any real-world use case. Because the erroneous break should be an error in any case, it had not been a real concern to create the Ruby exception a bit lazily. However, your case shows that there might be third-party code that doesn't behave, a code path that wasn't tested, or a piece of code that worked ok in one context but does not in another. In these cases, not propagating LJE as soon as possible makes it difficult to perform appropriate cleanup. I will point out, however, that ensure blocks should always still fire, and anything that should definitely happen before a concurrent-ruby task (for example) finishes executing, it should be in an ensure. Marking for 9.2, since I think we should tidy this up for many reasons. |
Here's a naive fix that searches the local thread context for the scope we're supposed to return to. This obviously adds overhead but I'm not sure the best way in IR to figure out that a proc has gone out of scope and needs to start raising LJE. @subbuss might have some ideas about how to handle this cases, where it's a valid break initially but becomes invalid once uprooted from the runtime state. diff --git a/core/src/main/java/org/jruby/ir/runtime/IRRuntimeHelpers.java b/core/src/main/java/org/jruby/ir/runtime/IRRuntimeHelpers.java
index 6ccba2f6db..3ddad78aa2 100644
--- a/core/src/main/java/org/jruby/ir/runtime/IRRuntimeHelpers.java
+++ b/core/src/main/java/org/jruby/ir/runtime/IRRuntimeHelpers.java
@@ -166,6 +166,10 @@ public class IRRuntimeHelpers {
IRScopeType scopeType = ensureScopeIsClosure(context, dynScope);
+ if (context.isScopeActive(dynScope.getParentScope())) {
+ throw context.runtime.newLocalJumpError(RubyLocalJumpError.Reason.BREAK, breakValue, "unexpected break");
+ }
+
// Raise a break jump so we can bubble back down the stack to the appropriate place to break from.
throw IRBreakJump.create(dynScope.getParentScope(), breakValue, scopeType.isEval()); // weirdly evals are impld as closures...yes yes.
}
diff --git a/core/src/main/java/org/jruby/runtime/ThreadContext.java b/core/src/main/java/org/jruby/runtime/ThreadContext.java
index 4d59343c53..f04f4eeb44 100644
--- a/core/src/main/java/org/jruby/runtime/ThreadContext.java
+++ b/core/src/main/java/org/jruby/runtime/ThreadContext.java
@@ -335,6 +335,13 @@ public final class ThreadContext {
scopeStack[scopeIndex--] = null;
}
+ public boolean isScopeActive(DynamicScope scope) {
+ for (int i = scopeIndex; i > 0; i--) {
+ if (scopeStack[i] == scope) return true;
+ }
+ return false;
+ }
+
private void expandScopeStack() {
int newSize = scopeStack.length * 2;
DynamicScope[] newScopeStack = new DynamicScope[newSize]; FWIW, simple breaks that don't span a huge number of frames should have pretty low overhead, but ideally we'd have none. |
Idea: add an "escaped" or "dead" bit to DynamicScope that we set when it is popped. Adds overhead to every pop (field set, virtual call). Unsure if this would be less overall overhead than the search. I've used the search in the past to look for current block's frame for similar reasons. |
Apparently that patch breaks a few things too, so it's a bit too deep. |
Maybe check if jruby/core/src/main/java/org/jruby/ir/runtime/IRRuntimeHelpers.java Lines 228 to 232 in 154275c
|
Nah, I don't think that will fix this since at the time of the break, the proc is still on the stack, and hence valid. |
Three potential places to handle this: If (c) is valid, that is the best. If not, (b) is probably the lower overhead approach. |
Note that the LocalJumpError should be thrown at the place of the break to be compatible with MRI: > proc { begin; break 42; rescue; p :rescued; end }.call
:rescued |
My original code for all this was for ruby 1.8/1.9 and not sure if semantics have changed a bit since then, or if this has always been broken. But, anyway, eregon's comment indicates that a break in a proc unconditionally throws a LJE? |
No, it throws a LocalJumpError only if the block is invoked by > def m(&b); b.call; end; m { break 42 }
=> 42 So this is related to break not allowed to go back and breaking from the proc definition: pr = proc { break 42 }
# The break would return 42 for "proc { break 42 }" and jump back in time if this worked!
pr.call |
Right, I meant: break in a |
So, @headius your original hunch is right ... this has to be handled in initiateBreak by examining the call stack. |
Since it appears that the LJE is thrown no matter what once the block has escaped, I think we have some better options. We already do mark blocks as "escaped" once they've been captured into a proc object. We just need to have access to that (probably via frame right now) and check at the time we initiate the break whether we should LJE or not. |
WFM. |
So a bit of a snag here: we're not setting blocks as escaped properly anymore. It appears to only be done via CachingCallSite.callIter now which isn't used by any call paths I can see. I had assumed we no longer needed this logic because IR was doing the right thing in all cases we cared about. This patch makes all specs and known examples pass (including @eregon's case) but obviously this only fixes it in the interpreter. diff --git a/core/src/main/java/org/jruby/ir/instructions/CallBase.java b/core/src/main/java/org/jruby/ir/instructions/CallBase.java
index 0628b32d90..c940645d26 100644
--- a/core/src/main/java/org/jruby/ir/instructions/CallBase.java
+++ b/core/src/main/java/org/jruby/ir/instructions/CallBase.java
@@ -425,6 +425,14 @@ public abstract class CallBase extends NOperandInstr implements ClosureAccepting
IRubyObject[] values = prepareArguments(context, self, currScope, dynamicScope, temp);
Block preparedBlock = prepareBlock(context, self, currScope, dynamicScope, temp);
+ if (getClosureArg() != null) {
+ try {
+ return callSite.call(context, self, object, values, preparedBlock);
+ } finally {
+ preparedBlock.escape();
+ }
+ }
+
return callSite.call(context, self, object, values, preparedBlock);
}
diff --git a/core/src/main/java/org/jruby/ir/interpreter/InterpreterEngine.java b/core/src/main/java/org/jruby/ir/interpreter/InterpreterEngine.java
index 0d606b49fb..730a33bc27 100644
--- a/core/src/main/java/org/jruby/ir/interpreter/InterpreterEngine.java
+++ b/core/src/main/java/org/jruby/ir/interpreter/InterpreterEngine.java
@@ -433,7 +433,7 @@ public class InterpreterEngine {
// This assumes that scopes with break instr. have a frame / dynamic scope
// pushed so that we can get to its static scope. For-loops now always have
// a dyn-scope pushed onto stack which makes this work in all scenarios.
- return IRRuntimeHelpers.initiateBreak(context, currDynScope, rv, blockType);
+ return IRRuntimeHelpers.initiateBreak(context, currDynScope, rv, block, blockType);
}
case NONLOCAL_RETURN: {
NonlocalReturnInstr ri = (NonlocalReturnInstr)instr;
diff --git a/core/src/main/java/org/jruby/ir/runtime/IRRuntimeHelpers.java b/core/src/main/java/org/jruby/ir/runtime/IRRuntimeHelpers.java
index 6ccba2f6db..fecb9c9eb9 100644
--- a/core/src/main/java/org/jruby/ir/runtime/IRRuntimeHelpers.java
+++ b/core/src/main/java/org/jruby/ir/runtime/IRRuntimeHelpers.java
@@ -159,15 +159,21 @@ public class IRRuntimeHelpers {
}
// FIXME: When we recompile lambdas we can eliminate this binary code path and we can emit as a NONLOCALRETURN directly.
- public static IRubyObject initiateBreak(ThreadContext context, DynamicScope dynScope, IRubyObject breakValue, Block.Type blockType) throws RuntimeException {
+ public static IRubyObject initiateBreak(ThreadContext context, DynamicScope dynScope, IRubyObject breakValue, Block block, Block.Type blockType) throws RuntimeException {
// Wrap the return value in an exception object and push it through the break exception
// paths so that ensures are run, frames/scopes are popped from runtime stacks, etc.
if (inLambda(blockType)) throw new IRWrappedLambdaReturnValue(breakValue);
IRScopeType scopeType = ensureScopeIsClosure(context, dynScope);
+ DynamicScope parentScope = dynScope.getParentScope();
+
+ if (block.isEscaped()) {
+ throw context.runtime.newLocalJumpError(RubyLocalJumpError.Reason.BREAK, breakValue, "unexpected break");
+ }
+
// Raise a break jump so we can bubble back down the stack to the appropriate place to break from.
- throw IRBreakJump.create(dynScope.getParentScope(), breakValue, scopeType.isEval()); // weirdly evals are impld as closures...yes yes.
+ throw IRBreakJump.create(parentScope, breakValue, scopeType.isEval()); // weirdly evals are impld as closures...yes yes.
}
// Are we within the scope where we want to return the value we are passing down the stack? Given that we already do have the callIter try/finally logic in CachingCallSite, I'll see what it would take to hook this up there and in indy. |
Is there is a way to detect 'if (block.isEscaped())' without having to set a flag? |
Too bad we cannot instantiate a new instance for a block which escapes and then have an intiateBreak() on it. Then we would have a bi-morphic call and no conditional/flag. The flag by itself makes me wonder if we already dup/clone the Block since I can campture the same block in a proc and non-proc context at the same time. |
Poking holes in my own idea...object instantiation itself is not cheap and I don't know how many places we may retain the original reference and whether there being two instances matters or not (if we do retain a reference somewhere). |
Two commits are in on fix_break_lje branch that set literal closures passed to calls as escaped once the call returns. This fixes all known specs and examples related to this issue. In the case of indy, it is implemented by wrapping the call in try/finally logic via method handles, which may not optimize well on older JVMs. Java 8 will do better, and 9 has introduced a tryFinally handle we can use. |
Note this affects super calls, and I have not done any fixes for that. They follow different invocation logic that will need to propagate whether a literal closure needs to be escaped. |
I have fixed the given cases. The remaining super cases are broken but we will get back to that some other time (perhaps when it is reported as an error, since it's likely to be very rare). @ivoanjo Please verify master. Thank you! |
Thanks so much @headius, @enebo and @subbuss for the great implementation brainstorm and quick fix! I can confirm that my original test case (and the real use case) using concurrent-ruby works correctly with current master. But one other test case I produced when I was trying to create the simplest reproduction test case still seems to be broken, so I will open a separate issue for that one. |
Fixes #4686. Fixes #4577. The logic here adds finally wrappers around all call paths that receive a block. When the call exits, the block is marked "escaped" since it no longer has a method activation to go with it. This indicates that non-local flow, like break, should immediately trigger a LocalJumpError. This passes specs but has not been tested extensively with other types of call forms that receive blocks.
Reopening to backport to 9.1.14. |
Environment
jruby 9.1.12.0 (2.3.3) 2017-06-15 33c6439 Java HotSpot(TM) 64-Bit Server VM 25.131-b11 on 1.8.0_131-b11 [linux-x86_64]
Linux maruchan 4.11.0-041100-generic #201705041534 SMP Thu May 4 19:36:05 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux
Ubuntu 17.04
Expected Behavior
The following test case causes a
LocalJumpError
exception to be raised, and then properly handles it.On MRI this works as expected:
Actual Behavior
Under JRuby, the code does not seem to be able to catch the
LocalJumpError
:The test case is pretty strange; the real use case where I've bumped into this issue is an app that took a block and created a future from it with
concurrent-ruby
. On a rare code path it would call an erroneousbreak
, and becauseconcurrent-ruby
could notrescue
and mark the background task as failed, the task would appear like it would never finish.The text was updated successfully, but these errors were encountered: