From cc9205f6d39473f3e3a6d70f3f7382d5ca44f3df Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki Date: Sat, 3 Oct 2026 22:16:37 +0900 Subject: [PATCH 1/2] Support JuliaInterpreter 0.12 JuliaInterpreter 0.12 interprets the code passed to `Core.eval` (JuliaDebug/JuliaInterpreter.jl#783), in frames with a `Module` scope below the frame of the calling method. `is_toplevel_return` took any frame with a `Module` scope paused at a return for the end of the current top-level expression. So stepping into code that a method evaluates, e.g. with `@eval`, ended the expression at the return of the evaluated code: the paused method frames were dropped, and the rest of the method never ran. `is_toplevel_return` now also requires that no method frame is on the caller chain, which is always the case with earlier JuliaInterpreter versions. The compat now allows JuliaInterpreter 0.12. The test steps through a method that calls `Core.eval`, with no compiled modules: `ALL_MODULES_EXCEPT_MAIN` would compile `Core`, which evaluates the code natively. Co-Authored-By: Claude Opus 5.5 --- Project.toml | 2 +- src/DebugEngines.jl | 11 ++++++++++- test/test_debugengine.jl | 38 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/Project.toml b/Project.toml index 6f8d967..1188e9e 100644 --- a/Project.toml +++ b/Project.toml @@ -23,5 +23,5 @@ Sockets = "< 0.0.1, 1" Pkg = "< 0.0.1, 1" Base64 = "< 0.0.1, 1" julia = "1" -JuliaInterpreter = "0.8.5, 0.9, 0.10, 0.11" +JuliaInterpreter = "0.8.5, 0.9, 0.10, 0.11, 0.12" JSON = "0.20, 0.21, 1" diff --git a/src/DebugEngines.jl b/src/DebugEngines.jl index 72e88de..a6bad57 100644 --- a/src/DebugEngines.jl +++ b/src/DebugEngines.jl @@ -322,7 +322,16 @@ function is_toplevel_return(frame) else false end - frame.framecode.scope isa Module && (JuliaInterpreter.isexpr(JuliaInterpreter.pc_expr(frame), :return) || is_a_return_node) + (JuliaInterpreter.isexpr(JuliaInterpreter.pc_expr(frame), :return) || is_a_return_node) || return false + # Only a return of the user's top-level code itself ends the top-level expression. Since + # JuliaInterpreter 0.12, code that a method evaluates with `Core.eval` runs in frames + # with a `Module` scope too, below the method's frame, and returning from them + # continues the method. + while frame !== nothing + frame.framecode.scope isa Module || return false + frame = frame.caller + end + return true end function our_debug_command(debug_engine::DebugEngine, cmd::Symbol) diff --git a/test/test_debugengine.jl b/test/test_debugengine.jl index 9eaa649..e56fcb5 100644 --- a/test/test_debugengine.jl +++ b/test/test_debugengine.jl @@ -124,7 +124,7 @@ end # Runs `code`, saved as a real file so file breakpoints can match it, in an engine # configured the way the extension does by default, and reports each stop on `stops`, # which is closed once the run ends. - function start_paused_engine(code, breakpoint_lines) + function start_paused_engine(code, breakpoint_lines; compiled_items = ["ALL_MODULES_EXCEPT_MAIN"]) JuliaInterpreter.remove() file = joinpath(mktempdir(), "paused.jl") write(file, code) @@ -136,7 +136,7 @@ end de = DebugEngines.DebugEngine(Module(:Paused), code, file, false, (reason, _...) -> put!(stops, reason)) # `ALL_MODULES_EXCEPT_MAIN` can never be applied for good, so it is retried after # every step, which is the path that used to throw away the paused framecodes. - DebugEngines.set_compiled_functions_modules!(de, ["ALL_MODULES_EXCEPT_MAIN"]) + DebugEngines.set_compiled_functions_modules!(de, compiled_items) task = @async try run(de) finally @@ -204,3 +204,37 @@ end JuliaInterpreter.remove() end end + +@testitem "stepping through code a method evaluates continues the method" setup=[PausedEngine] begin + # Since JuliaInterpreter 0.12, the code passed to `Core.eval` is interpreted in frames + # with a `Module` scope below the calling method's frame. Pausing at their return must + # not be taken for the end of the top-level expression, which would drop the method. + # `Core` has to stay interpreted for that, so the modules are not compiled here. + code = """ + function f() + Core.eval(@__MODULE__, :(evaluated = true)) + global after_eval = true + return nothing + end + f() + """ + de, file, stops, task = start_paused_engine(code, [2]; compiled_items = String[]) + try + @test next_stop(stops) == DebugEngines.StopReasonBreakpoint + @test paused_line(de) == 2 + + reason = nothing + for _ in 1:200 + DebugEngines.execution_step_in(de, missing) + reason = next_stop(stops) + reason === :finished && break + end + @test reason === :finished + @test invokelatest(isdefined, de.mod, :evaluated) + @test invokelatest(isdefined, de.mod, :after_eval) + finally + DebugEngines.terminate(de) + wait(task) + JuliaInterpreter.remove() + end +end From abcdf942e67d723085a6572f81817c617657ffe0 Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Sat, 3 Oct 2026 15:06:34 -0700 Subject: [PATCH 2/2] Qualify invokelatest in test for Julia < 1.9 Base only exports invokelatest from Julia 1.9 on. Co-Authored-By: Claude Opus 5.5 --- test/test_debugengine.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_debugengine.jl b/test/test_debugengine.jl index b30dac8..0a968b2 100644 --- a/test/test_debugengine.jl +++ b/test/test_debugengine.jl @@ -253,8 +253,8 @@ end reason === :finished && break end @test reason === :finished - @test invokelatest(isdefined, de.mod, :evaluated) - @test invokelatest(isdefined, de.mod, :after_eval) + @test Base.invokelatest(isdefined, de.mod, :evaluated) + @test Base.invokelatest(isdefined, de.mod, :after_eval) finally DebugEngines.terminate(de) wait(task)