From 5982e657f1980fffd7ce901008d1aa051a13ab92 Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Mon, 20 Jul 2026 08:43:25 +0200 Subject: [PATCH 1/2] Fix debugger source and expression display --- src/locationinfo.jl | 9 +++++++-- src/printing.jl | 4 ++++ test/misc.jl | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/locationinfo.jl b/src/locationinfo.jl index d92e2f8..38a6855 100644 --- a/src/locationinfo.jl +++ b/src/locationinfo.jl @@ -119,8 +119,13 @@ definition line unless `current_line` is `true`. function frame_location(frame::Frame; current_line::Bool=false) meth = frame.framecode.scope if meth isa Method - line = current_line ? JuliaInterpreter.linenumber(frame) : meth.line - path = string(_print_full_path[] ? meth.file : basename(String(meth.file)), ":", line) + if current_line + ret = JuliaInterpreter.whereis(frame) + file, line = ret === nothing ? (String(meth.file), JuliaInterpreter.linenumber(frame)) : ret + else + file, line = String(meth.file), meth.line + end + path = string(_print_full_path[] ? file : basename(file), ":", line) return CodeTracking.maybe_fix_path(path) else ret = JuliaInterpreter.whereis(frame) diff --git a/src/printing.jl b/src/printing.jl index 99f3d7f..c22abcf 100644 --- a/src/printing.jl +++ b/src/printing.jl @@ -226,6 +226,10 @@ end function expression_for_display(frame::Frame) expr = pc_expr(frame) + if expr isa JuliaInterpreter.SlotNumber + name = frame.framecode.src.slotnames[expr.id] + name === Symbol("") || return name + end if expr isa Union{GlobalRef, QuoteNode} && frame.pc < nstatements(frame.framecode) # Julia 1.12 may pause on a global lookup followed by separate argument # loads and then a call. Preview that call so the status still shows the diff --git a/test/misc.jl b/test/misc.jl index d791c0c..cf2a470 100644 --- a/test/misc.jl +++ b/test/misc.jl @@ -87,6 +87,9 @@ state = dummy_state(frame) execute_command(state, Val{:n}(), "n") defline, deffile, current_line, body = Debugger.locinfo(state.frame) @test occursin("handle_message(logger, level", body) +current_file, current_line = JuliaInterpreter.whereis(state.frame) +@test Debugger.frame_location(state.frame; current_line=true) == + CodeTracking.maybe_fix_path(string(current_file, ":", current_line)) f_unicode() = √ @@ -109,6 +112,18 @@ frame = @make_frame Test.TestLogger() desc = Debugger.locdesc(frame) @test occursin(Sys.STDLIB, desc) +f_slot_display(x) = (y = x + 1; y) +let frame = JuliaInterpreter.enter_call(f_slot_display, 1) + pc = findfirst(i -> pc_expr(frame, i) isa JuliaInterpreter.SlotNumber, + 1:JuliaInterpreter.nstatements(frame.framecode)) + if pc !== nothing + frame.pc = pc + slot = pc_expr(frame)::JuliaInterpreter.SlotNumber + name = frame.framecode.src.slotnames[slot.id] + @test chomp(sprint(Debugger.print_next_expr, frame)) == "→ $name" + end +end + import InteractiveUtils @testset "`o` command" begin g() = nothing From 90b57ff7f6100c87ad21e6f1cd3ee15f0a1b4634 Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Mon, 20 Jul 2026 19:33:06 +0200 Subject: [PATCH 2/2] some improvements in stepping --- src/locationinfo.jl | 35 +++++++++++++++++++++++++ src/repl.jl | 38 ++++++++++++++++++--------- test/misc.jl | 64 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 13 deletions(-) diff --git a/src/locationinfo.jl b/src/locationinfo.jl index 38a6855..8205d6b 100644 --- a/src/locationinfo.jl +++ b/src/locationinfo.jl @@ -11,6 +11,33 @@ function body_for_method(current_file, current_line, meth) end end +# Lowering attributes a function's implicit trailing `return nothing` to the +# line of the preceding statement, which for a function ending in a conditional +# is the last line of a branch that possibly never executed (#342). Detect that +# statement: the final `return nothing`, directly after another `return` with +# the same line attribution (the branch's own return). +function is_phantom_trailing_return(frame::Frame) + n = JuliaInterpreter.nstatements(frame.framecode) + frame.pc == n && n > 1 || return false + returns_nothing(stmt) = begin + val = stmt isa Core.ReturnNode ? stmt.val : + isexpr(stmt, :return) ? stmt.args[1] : missing + val === nothing || (val isa QuoteNode && val.value === nothing) + end + returns_nothing(pc_expr(frame, n)) || return false + JuliaInterpreter.is_return(pc_expr(frame, n - 1)) || return false + return JuliaInterpreter.linenumber(frame, n) == JuliaInterpreter.linenumber(frame, n - 1) +end + +# The line of the method's closing `end` (or of a one-line definition), or +# `nothing` if the source is unavailable +function method_end_line(meth::Method) + body_defline = CodeTracking.definition(String, meth) + body_defline === nothing && return nothing + body, defline = body_defline + return defline + countlines(IOBuffer(body)) - 1 +end + function locinfo(frame::Frame) scope = frame.framecode.scope if scope isa Method @@ -18,6 +45,10 @@ function locinfo(frame::Frame) ret = JuliaInterpreter.whereis(frame) ret === nothing && return nothing current_file, current_line = ret + if is_phantom_trailing_return(frame) + endline = method_end_line(meth) + endline === nothing || (current_line = endline) + end unknown_start = true if JuliaInterpreter.is_generated(meth) && !frame.framecode.generator # We're inside the expansion of a generated function. @@ -122,6 +153,10 @@ function frame_location(frame::Frame; current_line::Bool=false) if current_line ret = JuliaInterpreter.whereis(frame) file, line = ret === nothing ? (String(meth.file), JuliaInterpreter.linenumber(frame)) : ret + if is_phantom_trailing_return(frame) + endline = method_end_line(meth) + endline === nothing || (line = endline) + end else file, line = String(meth.file), meth.line end diff --git a/src/repl.jl b/src/repl.jl index 6aba9d1..f2882bb 100644 --- a/src/repl.jl +++ b/src/repl.jl @@ -138,20 +138,32 @@ function RunDebugger(frame, repl = nothing, terminal = nothing; initial_continue do_print_status = try execute_command(state, Val{Symbol(cmd1)}(), command) catch err - # This will only show the stacktrace up to the current frame because - # currently, the unwinding in JuliaInterpreter unlinks the frames to - # where the error is thrown - - # Buffer error printing. The error aborts the session, so it must - # survive leaving the alternate screen in sticky mode. - io = IOContext(IOBuffer(), output_stream(state)) - Base.display_error(io, err, JuliaInterpreter.leaf(state.frame)) - print_or_defer(state, String(take!(io.io))) - # Comment below out if you are debugging the Debugger - #Base.display_error(Base.pipe_writer(terminal), err, catch_backtrace()) - LineEdit.transition(s, :abort) + if state.frame === nothing + LineEdit.transition(s, :abort) + LineEdit.reset_state(s) + return false + end + buf = IOBuffer() + io = IOContext(buf, output_stream(state)) + if err isa InterruptException + printstyled(io, "Interrupted: the command was aborted, the debugger is still paused where it was.\n"; + color=Base.error_color()) + else + # JuliaInterpreter keeps the frames linked when an exception + # escapes the interpreted code, so the backtrace reaches the + # actual throw site + Base.display_error(io, err, JuliaInterpreter.leaf(state.frame)) + printstyled(io, "The error terminated the command; the debugger is still paused where it was.\n"; + color=:light_black) + # Comment below out if you are debugging the Debugger + #Base.display_error(Base.pipe_writer(terminal), err, catch_backtrace()) + end + # Drop the dead callee frames the failed command left behind so the + # session continues cleanly from the current statement + state.frame.callee = nothing + print_or_page(state, String(take!(buf))) LineEdit.reset_state(s) - return false + return true end if old_level != state.level panel.prompt = promptname(state.level, "debug") diff --git a/test/misc.jl b/test/misc.jl index cf2a470..c19212f 100644 --- a/test/misc.jl +++ b/test/misc.jl @@ -414,3 +414,67 @@ end JuliaInterpreter.remove() end + +# Issue #342: the implicit trailing `return nothing` of a function ending in a +# conditional is attributed to the last line of the (possibly never executed) +# branch; the debugger should point at the method's closing `end` instead +struct Circle342 end +function f_dead_branch(circle::Circle342) + if typeof(circle) == "Circle" + println("Thou shall not pass!") + println("Hello world!") + end +end + +@testset "phantom trailing return highlights the end line" begin + frame = JuliaInterpreter.enter_call(f_dead_branch, Circle342()) + state = dummy_state(frame) + execute_command(state, Val{:n}(), "n") + @test state.frame !== nothing + endline = first(methods(f_dead_branch)).line + 5 + _, _, current_line, _ = Debugger.locinfo(state.frame) + @test current_line == endline + @test endswith(Debugger.frame_location(state.frame; current_line=true), ":$endline") +end + +# A terminal LineEdit can run on without a real TTY (like the REPL stdlib's +# own FakeTerminals test helper): raw mode is a recorded no-op +mutable struct FakeTerminal <: REPL.Terminals.UnixTerminal + in_stream::Base.BufferStream + out_stream::IOBuffer + err_stream::IOBuffer + hascolor::Bool + raw::Bool + FakeTerminal() = new(Base.BufferStream(), IOBuffer(), IOBuffer(), false, false) +end +REPL.Terminals.hascolor(t::FakeTerminal) = t.hascolor +REPL.Terminals.raw!(t::FakeTerminal, raw::Bool) = t.raw = raw +Base.size(t::FakeTerminal) = (24, 80) + +function run_scripted_session(frame, input::String) + term = FakeTerminal() + write(term.in_stream, input) + repl = REPL.LineEditREPL(term, true) + repl.interface = REPL.setup_interface(repl) + RunDebugger(frame, repl, term) + return String(take!(term.out_stream)) +end + +function f_session_thrower(x) + y = x + 1 + z = sqrt(-1.0) + return y + z +end + +# An error escaping a command must not abort the session (fixes the worst part +# of stepping onto a throwing line): the error is shown with a backtrace down +# to the throw site and the debugger stays paused at the current statement +@testset "session survives errors in commands" begin + out = run_scripted_session(JuliaInterpreter.enter_call(f_session_thrower, 1), + "n\nn\nfr\nq\n") + @test occursin("DomainError", out) + @test occursin("throw_complex_domainerror", out) + @test occursin("still paused", out) + # the session survived: `fr` after the error still prints the locals + @test occursin(r"y.*= .*2", out) +end