From c7d17e4ccfc5cba8cb03768a1d93dc770e4ca4d8 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Sun, 11 Oct 2026 12:52:18 +0900 Subject: [PATCH] Require the `:leave`s and `:pop_exception`s of required `EnterNode`s `lines_required!` selects an `EnterNode` through `add_control_flow!` when its `try` block contains required statements, but nothing selects the `:leave`s exiting it, since `add_ssa_preds!` follows them only backward to the `EnterNode`. The selective evaluation then stays in the exception handler, and in the dynamic scope of the `EnterNode` if any, for the rest of the frame. For example, requiring both assignments below evaluates `outside` to `1` instead of `0`: Base.ScopedValues.@with sv => 1 begin global inside = sv[] end global outside = sv[] Likewise, an error thrown after a `try` block is caught by the handler left entered, which runs the statements after the `try` block again. This change adds the `add_handler_exits!` step, which requires the `:leave`s and `:pop_exception`s referencing a required `EnterNode`, so that the selective evaluation exits its handler and scope, and restores the exception stack after its `catch` block. On Julia 1.10, `:leave`s count the handlers to exit instead of referencing the `:enter`s, so only the `:pop_exception`s are required there. Callers with their own selection loops, e.g. JET and TestRunner, need to call `add_handler_exits!` to get this fix as well. Co-Authored-By: Claude Opus 5.5 --- src/codeedges.jl | 30 +++++++++++++++++++++++++++-- test/codeedges.jl | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/codeedges.jl b/src/codeedges.jl index 417e558..1f6bde3 100644 --- a/src/codeedges.jl +++ b/src/codeedges.jl @@ -765,6 +765,7 @@ function lines_required!( # Add control-flow changed |= add_control_flow!(isrequired, src, cfg, postdomtree) + changed |= add_handler_exits!(isrequired, src, edges, norequire) # So far, everything is generic graph traversal. Now we add some domain-specific information changed |= add_typedefs!(isrequired, src, edges, typedefs, norequire) @@ -900,8 +901,33 @@ function add_control_flow!(isrequired, src::CodeInfo, cfg::CFG, postdomtree) return changed[] end -is_conditional_terminator(@nospecialize stmt) = stmt isa GotoIfNot || - (@static @isdefined(EnterNode) ? stmt isa EnterNode : isexpr(stmt, :enter)) +is_conditional_terminator(@nospecialize stmt) = stmt isa GotoIfNot || is_enter(stmt) + +is_enter(@nospecialize stmt) = + @static @isdefined(EnterNode) ? stmt isa EnterNode : isexpr(stmt, :enter) + +# A required `EnterNode` enters an exception handler, and possibly a dynamic scope, which +# only the `:leave`s referencing it exit, so the statements after its `try` block would run +# within them otherwise. Likewise, the `:pop_exception`s referencing it restore the +# exception stack after its `catch` block. On Julia 1.10, `:leave`s count the handlers to +# exit instead of referencing them, so only the `:pop_exception`s are added. +function add_handler_exits!(isrequired, src::CodeInfo, edges::CodeEdges, norequire) + changed = false + for idx = 1:length(src.code) + stmt = src.code[idx] + isrequired[idx] && is_enter(stmt) || continue + for succ in edges.succs[idx] + isrequired[succ] && continue + succ ∈ norequire && continue + succstmt = src.code[succ] + if isexpr(succstmt, :leave) || isexpr(succstmt, :pop_exception) + isrequired[succ] = true + changed = true + end + end + end + return changed +end function is_conditional_block_active(isrequired, bb::BasicBlock, cfg::CFG, postdomtree) return visit_𝑰𝑵𝑭𝑳_blocks(bb, cfg, postdomtree) do postdominator::Int, 𝑰𝑵𝑭𝑳::BitSet diff --git a/test/codeedges.jl b/test/codeedges.jl index c66c7e6..75a188a 100644 --- a/test/codeedges.jl +++ b/test/codeedges.jl @@ -744,4 +744,53 @@ end end end +@static if isdefined(Core.IR, :EnterNode) + @testset "required `EnterNode`s are exited" begin + let mod = Module(:ModExitScope) + Core.eval(mod, :(const sv = Base.ScopedValues.ScopedValue(0))) + src = Meta.lower(mod, quote + Base.ScopedValues.@with sv => 1 begin + global inside = sv[] + end + global outside = sv[] + end).args[1] + edges = CodeEdges(mod, src) + controller = SelectiveEvalController() + objs = Set((GlobalRef(mod, :inside), GlobalRef(mod, :outside))) + isrequired = lines_required!(falses(length(src.code)), objs, src, edges, controller) + selective_eval_fromstart!( + LoweredCodeUtils.RecursiveInterpreter(), Frame(mod, src), + isrequired, controller, true) + @test @invokelatest(mod.inside) == 1 + @test @invokelatest(mod.outside) == 0 + end + + # A handler left entered would catch the error of `y`, and run it again + let mod = Module(:ModExitHandler) + Core.eval(mod, quote + hits = Symbol[] + boom() = (push!(hits, :boom); error("boom")) + end) + src = Meta.lower(mod, quote + try + global x = 1 + catch + global caught = true + end + global y = boom() + end).args[1] + edges = CodeEdges(mod, src) + controller = SelectiveEvalController() + objs = Set((GlobalRef(mod, :x), GlobalRef(mod, :y))) + isrequired = lines_required!(falses(length(src.code)), objs, src, edges, controller) + @test_throws ErrorException selective_eval_fromstart!( + LoweredCodeUtils.RecursiveInterpreter(), Frame(mod, src), + isrequired, controller, true) + @test @invokelatest(mod.x) == 1 + @test @invokelatest(mod.hits) == [:boom] + @test !@invokelatest(isdefined(mod, :caught)) + end + end +end + end # module codeedges