Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions src/locationinfo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,44 @@ 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
meth = scope
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.
Expand Down Expand Up @@ -119,8 +150,17 @@ 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
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
path = string(_print_full_path[] ? file : basename(file), ":", line)
return CodeTracking.maybe_fix_path(path)
else
ret = JuliaInterpreter.whereis(frame)
Expand Down
4 changes: 4 additions & 0 deletions src/printing.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 25 additions & 13 deletions src/repl.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
79 changes: 79 additions & 0 deletions test/misc.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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() = √

Expand All @@ -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
Expand Down Expand Up @@ -399,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