From 5d1429f0d6c9b701e180b56b98104ba52fcad14e Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Sun, 19 Jul 2026 13:20:05 +0200 Subject: [PATCH] Add mixed-mode stepping with a focus-set policy (new default) Interpreting everything through JuliaInterpreter makes stepping over Base/stdlib/dependency calls painfully slow, while marking them compiled via the JuliaInterpreter globals breaks stepping into user callbacks passed to higher-order functions (map, sort(by=...), broadcast, ...). This adds a MixedInterpreter that decides per call, at runtime: a call runs natively unless the callee or any argument carries a function or type from the "focus set" - the modules being debugged. The check walks runtime types including type parameters, so callbacks survive wrapper chains (Generator, Broadcasted, kwcall NamedTuples, Base.Fix1, ...) and calls dispatching on focus types stay interpreted, with no per-method blacklist. On a sum(sort(rand(100_000))) benchmark, `n` drops from 218s to 0.27s (~800x). The focus set is seeded per session: Main, packages loaded from dev checkouts (pkgdir outside the depots' read-only packages/ stores), and the entered method's package (except Base/stdlibs). Stepping into a dependency frame adds it for the session with an info message; stepping into Base/stdlibs prints a hint instead. For soundness the fast path is suspended (with a message) while break_on(:error/:throw) is active or a breakpoint targets a module that native code could reach, including transitively through reverse dependencies. UI/API: - three modes: interpreted, mixed (default), compiled; prompt shows 1|mixed> / 1|compiled> with per-mode colors - `M` toggles mixed<->interpreted, `C` keeps toggling compiled and returns to the previous mode; new `mode [interpreted|mixed|compiled]` - new `focus` command: list the set, `focus add Mod`, `focus rm Mod|i` - Debugger.set_default_mode!/default_mode, interpret_module!/ compile_module!/reset_module!, interpreted_modules() Documented limitations (escape hatch: interpreted mode): type-erased callbacks (Vector{Any}, ::Function fields), type piracy, and ambient execution (registries, Tasks, finalizers). Requires JuliaInterpreter 0.11 (native_call runs in frame.world, giving consistent Revise semantics). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 33 +- Project.toml | 4 +- README.md | 78 +++- src/Debugger.jl | 43 ++- src/commands.jl | 101 ++++++ src/config.jl | 4 +- src/menus.jl | 90 ++++- src/mixed_mode.jl | 681 +++++++++++++++++++++++++++++++++++ src/precompile.jl | 4 + src/repl.jl | 41 ++- test/menus.jl | 47 +++ test/mixed_mode.jl | 380 +++++++++++++++++++ test/runtests.jl | 1 + test/ui.jl | 5 + test/ui/history_gcd.multiout | 152 +++++++- 15 files changed, 1622 insertions(+), 42 deletions(-) create mode 100644 src/mixed_mode.jl create mode 100644 test/mixed_mode.jl diff --git a/CHANGELOG.md b/CHANGELOG.md index c94a97c..dbbc8a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,33 @@ ## Version 0.9.0 (unreleased) -This release overhauls the terminal UI. The most visible changes: the debugger -now runs full screen by default, `bp`/`f`/`w` open interactive menus, and a new -`debug>` REPL mode is entered with `)`. +This release adds mixed-mode stepping (the new default) and overhauls the +terminal UI. The most visible changes: stepping over Base/dependency code runs +at native speed, the debugger runs full screen by default, `bp`/`f`/`w`/`focus` +open interactive menus, and a new `debug>` REPL mode is entered with `)`. + +### Mixed-mode stepping — the new default + +Instead of interpreting everything (slow) or stepping over compiled code +blindly (breakpoints missed), the debugger now interprets only calls that can +reach the "focus set" — `Main`, packages loaded from dev checkouts, the package +you `@enter`ed, and anything you add — and runs everything else natively. +Callbacks and user types passed into Base or dependency code keep the carrying +call chain interpreted, so `map(f, xs)`, `sort(xs; by = f)`, broadcasts and +do-blocks remain fully debuggable while e.g. `sort(::Vector{Int})` runs at +compiled speed. Breakpoints anywhere outside the focus set automatically fall +back to full interpretation, with a message. + +- The prompt shows the mode: `1|debug>` (mixed, the default), + `1|interpreted>`, `1|compiled>` — each with its own color. +- `M` toggles mixed ↔ interpreted; `C` still toggles compiled mode. +- `mode [interpreted|mixed|compiled]` shows/sets the mode; + `Debugger.set_default_mode!(:interpreted)` (e.g. in `startup.jl`) restores + the previous default. +- `focus` shows and manages the focus set; stepping into a dependency package + adds it to the set for the session (a message is printed). +- API: `Debugger.interpret_module!`, `Debugger.compile_module!`, + `Debugger.reset_module!`, `Debugger.interpreted_modules`. ### Keys at the `debug>` prompt @@ -14,6 +38,7 @@ Pressed at the beginning of an empty prompt line: |:----|:-------| | `` ` `` | enter evaluation mode (as before) | | `C` | toggle compiled mode (as before) | +| `M` | **new** — toggle mixed mode | | `L` | toggle lowered code (as before) | | `T` | **new** — cycle how variable types are shown: compact → none → types only → full | | `S` | **new** — toggle "sticky" (full-screen) mode | @@ -36,6 +61,8 @@ scrolling transcript back. are toggleable rows in the same menu. - `f` (no arguments) opens a frame picker for the call stack. - `w` (no arguments) opens the watch list (**`d`** deletes an entry). +- `focus` (no arguments) opens the mixed-mode focus set: **space/enter** + toggles whether a module is interpreted, **`a`** adds one by name. All existing subcommands (`bp rm 2`, `bp add foo:12`, `f 3`, `w rm 1`, ...) still work, and non-interactive terminals automatically get the old text diff --git a/Project.toml b/Project.toml index 958bf2b..ed53c2f 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "Debugger" uuid = "31a5f54b-26ea-5ae9-a837-f05ce5417438" -version = "0.8.1" +version = "0.9.0" [deps] CodeTracking = "da1fd8a2-8d9e-5ec2-8556-3022fb5608a2" @@ -18,7 +18,7 @@ TerminalRegressionTests = {url = "https://github.com/JuliaDebug/TerminalRegressi [compat] CodeTracking = "2, 3" Highlights = "0.6" -JuliaInterpreter = "0.10, 0.11" +JuliaInterpreter = "0.11" PrecompileTools = "1" TerminalRegressionTests = "0.3" julia = "1.10" diff --git a/README.md b/README.md index 8e5372b..b42b823 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ Misc: - `o`: open the current line in an editor - `q`: quit the debugger, returning `nothing` - `C`: toggle compiled mode +- `M`: toggle mixed mode (see "Stepping modes" below) +- `mode [interpreted|mixed|compiled]`: show or set the stepping mode - `L`: toggle showing lowered code instead of source code - `T`: cycle how variable types are shown: compact (long type parameters elided), no types, types only, full types - `S`: toggle "sticky" (full-screen) mode, on by default: the debugger runs on the terminal's alternate screen (restored when quitting, like `less` or `vim`) and redraws the status in place instead of scrolling @@ -232,28 +234,74 @@ Hit breakpoint: >[1] f(x) at REPL[6]:3 ``` -### Compiled mode +### Stepping modes: interpreted, mixed and compiled -In order to fully support breakpoints, the debugger interprets all code, even code that is stepped over. -Currently, there are cases where the interpreter is too slow for this to be feasible. -A workaround is to use "compiled mode" which is toggled by pressing `C` in the debug REPL mode (note the change of prompt color). -When using compiled mode, code that is stepped over will be executed -by the normal julia compiler and run just as fast as normally. -The drawback is that breakpoints in compiled code that is stepped over are missed. +In order to fully support breakpoints, the debugger can interpret all code, even code that is stepped +over. Currently, there are cases where the interpreter is too slow for this to be feasible. The +debugger therefore supports three stepping modes: -To split the difference between these two extremes, one can fine tune which modules are compiled and which are not. -For example, to compile all code in Base, even when not in `C` mode, and hence break on all specified points in user code, -issue the following commands on the REPL *before* `@enter`: +- **mixed** (the default): calls into modules outside the "focus set" — `Core`, `Base`, the standard + libraries *and* dependency packages — run natively, at full compiled speed, *unless* the call could + reach focus code. A call is interpreted whenever the callee or any argument carries a function or + type from a focus module, also inside wrappers: `map(f, xs)`, `sort(xs; by = f)`, `f.(xs)`, + do-blocks, `Vector{MyType}` arguments and so on all remain fully debuggable, while e.g. + `sort(::Vector{Int})` or a `DataFrames` call operating purely on its own types runs at native + speed. As the default, it keeps the plain `1|debug>` prompt. +- **interpreted**: everything is interpreted; breakpoints always work, including the corner cases + mixed mode cannot catch (see below). Toggled with the `M` key (note the `1|interpreted>` prompt + and its color). +- **compiled**: everything that is stepped over runs natively. Fastest, but breakpoints in code that + is stepped over are missed. Toggled with the `C` key (note the `1|compiled>` prompt and its color). + +The mode can also be shown/set with the `mode [interpreted|mixed|compiled]` command in the debug REPL, +and the mode new sessions start in can be set (e.g. in `startup.jl`) with: ```jl -using JuliaInterpreter, MethodAnalysis -union!(JuliaInterpreter.compiled_modules, child_modules(Base)) +Debugger.set_default_mode!(:interpreted) ``` -Additional imported modules can also always be compiled with: +The focus set — the modules mixed mode interprets — is seeded automatically at each `@enter`/`@run`: + +- `Main` (functions defined at the REPL or in scripts); +- every loaded package whose source is a *dev checkout* (a `pkgdir` outside the read-only + `~/.julia/packages` store), i.e. the packages you are plausibly developing; +- the package of the function you entered (so `@enter DataFrames.groupby(df, :a)` makes `DataFrames` + debuggable even though it is a registered install) — except `Base`/stdlibs, which would de-facto + disable the fast path; +- stepping into (`s`) a frame of a dependency package adds it to the focus set for the session (an + info message is printed). Stepping into `Base`/a stdlib does *not* — a hint is printed instead. + +Inside the debugger, `focus` opens an interactive menu over the current set (space/enter toggles a +module, `a` adds one by name); `focus add SomeMod` / `focus rm SomeMod` (or `focus rm i` by number) +do the same non-interactively. These adjust the session; the permanent equivalents, e.g. for +`startup.jl`: ```jl -union!(JuliaInterpreter.compiled_modules, SomePackage) +Debugger.interpret_module!(SparseArrays) # always interpret (debug into) this module +Debugger.compile_module!(MyBigHelperPkg) # never interpret this module (also skips its `@bp` markers) +Debugger.interpreted_modules() # show the current focus set +``` + +For soundness, mixed mode automatically falls back to full interpretation while any breakpoint is set +outside the focus set (including file breakpoints that could match such a module), while a breakpoint +is set in a focus package that loaded non-focus packages depend on (native code in the latter could +otherwise run past it), or while `break_on(:error)` or `break_on(:throw)` is active — a message is +printed when this happens. Known limitations of mixed mode (switch to interpreted mode if they +matter): focus-module callbacks hidden in type-erased containers (e.g. `Vector{Any}`, fields typed +`::Function`) are not detected; neither are focus-module methods whose signatures only mention +non-focus types (type piracy), nor focus code reached without any focus-typed argument at the call +site (callbacks stored in global registries, `Task`s, finalizers). `@bp` markers inside non-focus +modules are skipped, and file breakpoints on source that was `eval`'d or `include`d from outside a +package directory may not trigger the fallback. + +The [`JuliaInterpreter.compiled_methods`/`compiled_modules`](https://github.com/JuliaDebug/JuliaInterpreter.jl) +mechanisms keep working in every mode and take precedence over mixed mode's decisions +(as does `JuliaInterpreter.interpreted_methods`, in the other direction). For example, to compile all +code in Base even in interpreted mode: + +```jl +using JuliaInterpreter, MethodAnalysis +union!(JuliaInterpreter.compiled_modules, child_modules(Base)) ``` @@ -270,7 +318,7 @@ shows the current settings. The available options are: - `max_vars::Int`: maximum number of variables shown in the automatic status display (default: `15`) - `sticky::Bool`: "full screen" mode — the debugger runs on the terminal's alternate screen and redraws the status in place instead of scrolling (default: `true`); can also be toggled with the `S` key - `charset::Symbol`: `:unicode` or `:ascii` (default: `:unicode`) -- `menus::Bool`: use the interactive menus for `bp`, `f` and `w` (default: `true`) +- `menus::Bool`: use the interactive menus for `bp`, `f`, `w` and `focus` (default: `true`) The older entry points `Debugger.set_theme(theme)` and `Debugger.set_highlight(false)` still work. diff --git a/src/Debugger.jl b/src/Debugger.jl index 2e385b8..15e88c8 100644 --- a/src/Debugger.jl +++ b/src/Debugger.jl @@ -21,6 +21,7 @@ const SEARCH_PATH = [] function __init__() append!(SEARCH_PATH,[joinpath(Sys.BINDIR,"../share/julia/base/"), joinpath(Sys.BINDIR,"../include/")]) + init_mixed_mode() auto_install_repl_mode() return nothing end @@ -38,13 +39,17 @@ using .LineNumbers: SourceFile, compute_line # watch expressions between invocations of the debugger interface const WATCH_LIST = [] +include("mixed_mode.jl") + Base.@kwdef mutable struct DebuggerState frame::Union{Nothing, Frame} level::Int = 1 broke_on_error::Bool = false watch_list::Vector = WATCH_LIST lowered_status::Bool = false - interp::Interpreter = RecursiveInterpreter() + interp::Interpreter = interp_for_mode(DEFAULT_MODE[]) + # the mode to come back to when the `C` (compiled mode) toggle is switched off + noncompiled_interp::Interpreter = RecursiveInterpreter() repl = nothing terminal = nothing main_mode = nothing @@ -65,10 +70,44 @@ function output_stream(state::DebuggerState) return IOContext(io, :color => have_color) end +# `C` key: binary toggle between compiled mode and the previous non-compiled mode function toggle_mode(state) - state.interp = (state.interp === RecursiveInterpreter() ? (state.interp = NonRecursiveInterpreter()) : (state.interp = RecursiveInterpreter())) + if state.interp isa NonRecursiveInterpreter + state.interp = state.noncompiled_interp + else + state.noncompiled_interp = state.interp + state.interp = NonRecursiveInterpreter() + end +end + +# `M` key: binary toggle between mixed and interpreted mode +function toggle_mixed(state) + if state.interp isa MixedInterpreter + state.interp = RecursiveInterpreter() + else + state.interp = MixedInterpreter() + end + state.noncompiled_interp = state.interp end +function set_session_mode!(state, mode::Symbol) + new_interp = interp_for_mode(mode) + if new_interp isa NonRecursiveInterpreter + # remember the current mode so the `C` toggle returns to it + if !(state.interp isa NonRecursiveInterpreter) + state.noncompiled_interp = state.interp + end + else + state.noncompiled_interp = new_interp + end + state.interp = new_interp + return state.interp +end + +mode_description(::RecursiveInterpreter) = "interpreted (all code is interpreted, breakpoints always work)" +mode_description(::MixedInterpreter) = "mixed (code outside the focus modules runs natively unless it can reach them)" +mode_description(::NonRecursiveInterpreter) = "compiled (stepped-over code runs natively, breakpoints in it are missed)" + toggle_lowered(state) = state.lowered_status = !state.lowered_status function active_frame(state) diff --git a/src/commands.jl b/src/commands.jl index f25861b..dc28376 100644 --- a/src/commands.jl +++ b/src/commands.jl @@ -43,6 +43,7 @@ function execute_command(state::DebuggerState, v::Union{Val{:c},Val{:nc},Val{:n} assert_allow_step(state) || return false cmd == "so" && (cmd = "finish") cmd == "u" && (cmd = "until") + maybe_warn_native_suppressed(state) ret = debug_command(state.interp, state.frame, Symbol(cmd); kwargs...) if ret === nothing state.overall_result = get_return(root(state.frame)) @@ -50,6 +51,10 @@ function execute_command(state::DebuggerState, v::Union{Val{:c},Val{:nc},Val{:n} return false else state.frame, pc = ret + # an explicit step-in may land in a module outside the focus set + if v isa Union{Val{:s},Val{:si},Val{:sg},Val{:sl}} + maybe_grow_focus!(state) + end if pc isa BreakpointRef if pc.stmtidx != 0 # This is the dummy breakpoint to stop just after entering a call if state.terminal !== nothing # fix this, it happens when a test hits this and hasn't set a terminal @@ -282,6 +287,98 @@ function execute_command(state::DebuggerState, v::Union{Val{:bp}}, cmd::Abstract end +function execute_command(state::DebuggerState, ::Val{:mode}, cmd::AbstractString) + cmds = split(cmd, r" +") + io = output_stream(state) + if length(cmds) == 1 + println(io, "Stepping mode: ", mode_description(state.interp)) + if state.interp isa MixedInterpreter + reason = native_suppressed_reason() + if reason === nothing + println(io, "Focus (interpreted) modules: ", + join(nameof.(interpreted_modules()), ", "), " (manage with `focus`)") + else + printstyled(io, "Fast path suspended, running fully interpreted ($reason)\n"; color=:yellow) + end + end + return false + elseif length(cmds) == 2 + mode = Symbol(cmds[2]) + if mode in (:interpreted, :mixed, :compiled) + set_session_mode!(state, mode) + println(io, "Stepping mode: ", mode_description(state.interp)) + return false + end + end + return invalid_command(state, cmd) +end + +function _resolve_module(state, name::AbstractString) + ex = try + Meta.parse(name) + catch + return nothing + end + for m in (state.frame === nothing ? (Main,) : (moduleof(active_frame(state)), Main)) + val = try + Core.eval(m, ex) + catch + continue + end + val isa Module && return val + end + # fall back to loaded packages without a binding in the above modules + matches = Set{Module}() + for (_, m) in Base.loaded_modules + string(nameof(m)) == name && push!(matches, m) + end + length(matches) == 1 && return first(matches) + if length(matches) > 1 + printstyled(stderr, "Multiple loaded modules named $name\n"; color=Base.error_color()) + end + return nothing +end + +function execute_command(state::DebuggerState, ::Val{:focus}, cmd::AbstractString) + cmds = split(cmd, r" +") + io = output_stream(state) + function print_focus() + println(io, "Focus set (modules that are interpreted; everything else runs natively when possible):") + for (i, m) in enumerate(interpreted_modules()) + println(io, " [$i] $m") + end + end + if length(cmds) == 1 + if menus_available(state) + focus_menu(state) + else + print_focus() + end + return false + elseif length(cmds) == 3 && cmds[2] in ("add", "rm") + local mod + i = cmds[2] == "rm" ? tryparse(Int, cmds[3]) : nothing + if i !== nothing + mods = interpreted_modules() + if !(1 <= i <= length(mods)) + printstyled(stderr, "No module numbered $i in the focus set\n"; color=Base.error_color()) + return false + end + mod = mods[i] + else + mod = _resolve_module(state, cmds[3]) + if mod === nothing + printstyled(stderr, "Could not resolve $(repr(cmds[3])) as a loaded module\n"; color=Base.error_color()) + return false + end + end + cmds[2] == "add" ? focus_module!(mod) : unfocus_module!(mod) + print_focus() + return false + end + return invalid_command(state, cmd) +end + function execute_command(state::DebuggerState, _, cmd) display(Markdown.parse("""Unknown command `$cmd`. Run `?` to obtain help.""")) return false @@ -297,6 +394,10 @@ function execute_command(state::DebuggerState, ::Union{Val{:help}, Val{:?}}, cmd - `o`: open the current line in an editor\\ - `q`: quit the debugger, returning `nothing`\\ - `C`: toggle compiled mode\\ + - `M`: toggle mixed mode (code outside the focus modules runs natively unless it can reach them)\\ + - `mode [interpreted|mixed|compiled]`: show or set the stepping mode\\ + - `focus`: show the focus set (the modules mixed mode interprets) as an interactive menu\\ + - `focus add Mod`/`focus rm Mod|i`: add/remove a module (by name or number) from the focus set\\ - `L`: toggle showing lowered code instead of source code\\ - `T`: cycle how variable types are shown (compact, none, types only, full)\\ - `S`: toggle "sticky" (full-screen) mode, on by default: the debugger runs on the terminal's alternate screen and redraws the status in place\\ diff --git a/src/config.jl b/src/config.jl index 18e9077..3802897 100644 --- a/src/config.jl +++ b/src/config.jl @@ -23,7 +23,7 @@ const STICKY = Ref(true) const CHARSET = Ref{Symbol}(:unicode) -# Interactive TerminalMenus-based menus for `bp`, `f` and `w` +# Interactive TerminalMenus-based menus for `bp`, `f`, `w` and `focus` const INTERACTIVE_MENUS = Ref(true) set_theme(theme::String) = _current_theme[] = theme @@ -53,7 +53,7 @@ settings as a `NamedTuple`; keywords set the corresponding option: screen (restored on quit) and redraws the status in place instead of scrolling (default: `true`). Can be toggled with the `S` key in the debugger. - `charset::Symbol`: `:unicode` or `:ascii` (default: `:unicode`) -- `menus::Bool`: use interactive menus for `bp`, `f` and `w` (default: `true`) +- `menus::Bool`: use interactive menus for `bp`, `f`, `w` and `focus` (default: `true`) """ function config(; theme::Union{Nothing,String} = nothing, highlight::Union{Nothing,Bool} = nothing, diff --git a/src/menus.jl b/src/menus.jl index a39cffe..dcc8bf8 100644 --- a/src/menus.jl +++ b/src/menus.jl @@ -1,7 +1,7 @@ -# Interactive menus (REPL.TerminalMenus) for breakpoints, frames and watch -# expressions. All of them are driven by `ActionMenu`, a generic menu where -# every row is an arbitrary object rendered by a callback and key presses map -# to actions. +# Interactive menus (REPL.TerminalMenus) for breakpoints, frames, watch +# expressions and the mixed-mode focus set. All of them are driven by +# `ActionMenu`, a generic menu where every row is an arbitrary object rendered +# by a callback and key presses map to actions. function menus_available(state::DebuggerState) INTERACTIVE_MENUS[] || return false @@ -275,3 +275,85 @@ function watch_menu(state::DebuggerState) run_menu(menu, state) return nothing end + +# --- focus menu -------------------------------------------------------------- + +# Modules worth showing: the current focus set plus everything toggled off (so a +# toggle is reversible without retyping the name), the permanent overrides, and +# the Base/stdlib modules the user entered or stepped into (not auto-focused, +# but one toggle away). +function focus_menu_rows() + mods = Set{Module}(focus_modules()) + union!(mods, SESSION_UNFOCUSED) + union!(mods, INTERPRET_OVERRIDES) + union!(mods, COMPILE_OVERRIDES) + union!(mods, _stdlib_focus_hinted) + return sort!(collect(mods); by = m -> string(nameof(m))) +end + +function focus_menu_writerow(io::IO, mod::Module, idx::Int) + infocus = is_focus_module(mod) + printstyled(io, infocus ? char_bp_enabled() : char_bp_disabled(); + color = infocus ? :green : :light_black) + print(io, " ", idx, "] ") + printstyled(io, string(mod); color = infocus ? :normal : :light_black) + if mod ∈ INTERPRET_OVERRIDES || mod ∈ COMPILE_OVERRIDES + printstyled(io, " (pinned)"; color = :light_black) + elseif mod === SESSION_ENTRY_ROOT[] + printstyled(io, " (entered)"; color = :light_black) + end +end + +focus_menu_toggle(mod::Module) = + is_focus_module(mod) ? unfocus_module!(mod) : focus_module!(mod) + +function focus_menu(state::DebuggerState) + add_requested = Ref(false) + cursor = 1 + + onkey = function (m, key, idx) + if key == ' ' || key == 't' + focus_menu_toggle(m.rows[idx]::Module) + elseif key == 'a' + # adding needs a free-form module name; leave the menu, prompt for + # one line and reopen + add_requested[] = true + return true + end + return false + end + onpick = function (m, idx) + focus_menu_toggle(m.rows[idx]::Module) + return false + end + + while true + rows = focus_menu_rows() + add_requested[] = false + + menu = ActionMenu(rows; writerow = focus_menu_writerow, onkey = onkey, onpick = onpick, + help = "[space/enter] toggle [a] add [q] quit", + menu_settings(state)...) + run_menu(menu, state; cursor = cursor) + + add_requested[] || break + io = output_stream(state) + printstyled(io, "focus add> "; color = :light_black) + input = try + strip(readline(state.terminal.in_stream)) + catch + "" + end + if !isempty(input) + mod = _resolve_module(state, String(input)) + if mod === nothing + printstyled(stderr, "Could not resolve $(repr(input)) as a loaded module\n"; color=Base.error_color()) + else + focus_module!(mod) + # reopen with the cursor on the newly added module + cursor = something(findfirst(==(Base.moduleroot(mod)), focus_menu_rows()), 1) + end + end + end + return nothing +end diff --git a/src/mixed_mode.jl b/src/mixed_mode.jl new file mode 100644 index 0000000..a45ffe8 --- /dev/null +++ b/src/mixed_mode.jl @@ -0,0 +1,681 @@ +# Mixed-mode execution: run "boring" calls (Base/stdlib code operating purely on +# Base/stdlib data) natively, while interpreting every call that could lead to user +# code — user functions passed as arguments (also inside wrappers like `Generator`, +# `Broadcasted` or keyword-call `NamedTuple`s) and calls that may dispatch on user +# types. +# +# All policy state below is process-global and unsynchronized, like the rest of +# Debugger/JuliaInterpreter (breakpoints, WATCH_LIST, compiled_methods): one debug +# session at a time is assumed; nested or concurrent sessions share and reseed it. + +""" + MixedInterpreter <: JuliaInterpreter.Interpreter + +An interpreter that recurses (like `RecursiveInterpreter`) into any call that can +reach user code, but executes calls natively (like `NonRecursiveInterpreter`) when +both the callee and all arguments belong to "compiled" modules (by default `Core`, +`Base` and the standard libraries). See `Debugger.compile_module!`, +`Debugger.interpret_module!` and the `mode` debugger command. +""" +struct MixedInterpreter <: Interpreter end + +# ------------------------------------------------------------------ +# Module classification: the focus set +# ------------------------------------------------------------------ + +# Mixed mode interprets the modules being debugged — the "focus set" — and runs +# everything else (Base, stdlibs *and* dependency packages) natively when possible. +# The focus set is seeded per debug session: `Main`, packages loaded from dev +# checkouts (a `pkgdir` outside every depot's read-only `packages/` store), the +# root module of the entered method, and the permanent overrides below. + +const STDLIB_NAMES = Set{Symbol}() +const COMPILE_OVERRIDES = Set{Module}() # roots forced out of focus via compile_module! +const INTERPRET_OVERRIDES = Set{Module}() # roots forced into focus via interpret_module! +const FOCUS = Ref{Union{Nothing,Set{Module}}}(nothing) # nothing = seed on first use +const _dev_package_cache = Dict{Module,Bool}() + +# julia's compiler is a separate root module on ≥1.12; matched by identity so a +# user package that happens to be called `Compiler` is not misclassified +const _compiler_root = Ref{Union{Nothing,Module}}(nothing) + +is_stdlib_root(root::Module) = + root === Base || root === Core || nameof(root) ∈ STDLIB_NAMES || + root === _compiler_root[] + +_maybe_realpath(path::String) = try realpath(path) catch; normpath(path) end + +# Component-aware containment: `packages-dev` is not inside `packages`. +_path_within(path::String, dir::String) = path == dir || startswith(path, joinpath(dir, "")) + +_pkg_devdir() = _maybe_realpath(get(ENV, "JULIA_PKG_DEVDIR") do + joinpath(isempty(DEPOT_PATH) ? joinpath(homedir(), ".julia") : DEPOT_PATH[1], "dev") +end) + +# "Dev checkout" — source the user can plausibly be editing/debugging. Registered +# installs live in a depot's content-addressed (read-only) `packages/` store. +function is_dev_package(root::Module) + get!(_dev_package_cache, root) do + root === Main && return true + is_stdlib_root(root) && return false + dir = pkgdir(root) + dir === nothing && return true # non-package root, e.g. eval'd module + rdir = _maybe_realpath(dir) + _path_within(rdir, _pkg_devdir()) && return true # explicit devdir wins + for depot in DEPOT_PATH + pkgs = joinpath(depot, "packages") + isdir(pkgs) || continue + _path_within(rdir, _maybe_realpath(pkgs)) && return false + end + return !_path_within(rdir, _maybe_realpath(Sys.STDLIB)) + end +end + +function default_focus() + focus = Set{Module}((Main,)) + for (_, m) in Base.loaded_modules + root = Base.moduleroot(m) + is_dev_package(root) && push!(focus, root) + end + union!(focus, INTERPRET_OVERRIDES) + setdiff!(focus, COMPILE_OVERRIDES) + setdiff!(focus, SESSION_UNFOCUSED) # respected on mid-session (lazy) recomputation + return focus +end + +function focus_modules() + focus = FOCUS[] + focus === nothing || return focus + return FOCUS[] = default_focus() +end + +is_focus_module(root::Module) = root ∈ focus_modules() +is_compiled_module(root::Module) = !is_focus_module(root) + +# Reseed the focus set at the start of a debug session. The entered method's root +# module joins the focus so that `@enter SomePkg.f(...)` makes `SomePkg` debuggable +# even when it is a registered install — except for Base/stdlibs, where focusing +# would de-facto disable the fast path (the entered frame itself is always +# interpreted anyway, so stepping inside it works regardless). +const SESSION_ENTRY_ROOT = Ref{Union{Nothing,Module}}(nothing) +const _pending_entry_hint = Ref{Union{Nothing,Module}}(nothing) + +# Printed once per entered stdlib module, after the first status draw of the +# session (a message printed earlier is erased by entering the alternate screen +# in sticky mode). +function maybe_print_entry_hint(state) + mod = _pending_entry_hint[] + mod === nothing && return nothing + _pending_entry_hint[] = nothing + state.interp isa MixedInterpreter || return nothing + printstyled(output_stream(state), + "$mod is not added to the focus set when entered; calls to it from " * + "other frames still run natively (`focus add $(nameof(mod))` to " * + "interpret it everywhere)\n"; color=:light_black) + return nothing +end + +function seed_focus!(frame) + empty!(SESSION_UNFOCUSED) # session choices do not survive into a new session + SESSION_ENTRY_ROOT[] = nothing + focus = default_focus() + scope = frame === nothing ? nothing : JuliaInterpreter.scopeof(root(frame)) + mod = scope isa Method ? scope.module : scope + if mod isa Module + entry = Base.moduleroot(mod) + if !is_stdlib_root(entry) + # recorded even when overridden, so reset_module! can restore it + SESSION_ENTRY_ROOT[] = entry + entry ∉ COMPILE_OVERRIDES && push!(focus, entry) + elseif entry ∉ INTERPRET_OVERRIDES && entry ∉ _stdlib_focus_hinted + # entering Base/a stdlib does not focus it (that would de-facto + # disable the fast path); note it once, and the `focus` menu shows + # it as a toggleable row. The message itself is printed by + # `maybe_print_entry_hint` after the first status draw — printing + # here would be swallowed by the switch to the alternate screen. + push!(_stdlib_focus_hinted, entry) + _pending_entry_hint[] = entry + end + end + _loaded_fingerprint[] = length(Base.loaded_modules) + if FOCUS[] != focus + FOCUS[] = focus + invalidate_policy_cache!() + end + return focus +end + +# Packages can be loaded mid-session (e.g. from the evaluation prompt); newly +# loaded dev packages join the focus set and the source-path caches used for +# file-breakpoint suppression must be rebuilt. Checked once per debugger command. +const _loaded_fingerprint = Ref(-1) + +function refresh_loaded_modules!() + n = length(Base.loaded_modules) + n == _loaded_fingerprint[] && return nothing + _loaded_fingerprint[] = n + if FOCUS[] !== nothing + for (_, m) in Base.loaded_modules + root = Base.moduleroot(m) + if is_dev_package(root) && root ∉ SESSION_UNFOCUSED && root ∉ COMPILE_OVERRIDES + push!(FOCUS[], root) + end + end + end + invalidate_policy_cache!() + return nothing +end + +# Session-scoped focus manipulation (the `focus` debugger command). Roots removed +# with `focus rm` are remembered so automatic focus growth does not re-add them. +const SESSION_UNFOCUSED = Set{Module}() + +function focus_module!(mod::Module) + root = Base.moduleroot(mod) + delete!(SESSION_UNFOCUSED, root) + added = root ∉ focus_modules() + added && (push!(focus_modules(), root); invalidate_policy_cache!()) + return added +end + +function unfocus_module!(mod::Module) + root = Base.moduleroot(mod) + push!(SESSION_UNFOCUSED, root) + removed = root ∈ focus_modules() + removed && (delete!(focus_modules(), root); invalidate_policy_cache!()) + return removed +end + +""" + Debugger.compile_module!(mod::Module) + +Permanently force `mod` (more precisely, its root module) out of mixed mode's focus +set: calls into it run natively when no argument carries focus code. Note that this +also skips any inline `@bp` markers inside `mod`. For the current session only, use +the `focus rm` debugger command instead. +""" +function compile_module!(mod::Module) + root = Base.moduleroot(mod) + delete!(INTERPRET_OVERRIDES, root) + push!(COMPILE_OVERRIDES, root) + FOCUS[] === nothing || delete!(FOCUS[], root) + invalidate_policy_cache!() + return nothing +end + +""" + Debugger.interpret_module!(mod::Module) + +Permanently add `mod` (more precisely, its root module) to mixed mode's focus set: +calls into it, and calls whose arguments carry types from it, are always +interpreted. Use this to debug into a standard library or a registered dependency. +For the current session only, use the `focus add` debugger command instead. +""" +function interpret_module!(mod::Module) + root = Base.moduleroot(mod) + delete!(COMPILE_OVERRIDES, root) + push!(INTERPRET_OVERRIDES, root) + FOCUS[] === nothing || push!(FOCUS[], root) + invalidate_policy_cache!() + return nothing +end + +""" + Debugger.reset_module!(mod::Module) + +Remove any permanent [`interpret_module!`](@ref)/[`compile_module!`](@ref) override +for `mod`, returning it to automatic focus classification. +""" +function reset_module!(mod::Module) + root = Base.moduleroot(mod) + delete!(COMPILE_OVERRIDES, root) + delete!(INTERPRET_OVERRIDES, root) + # reclassify only this root; other session focus choices are kept + if FOCUS[] !== nothing + automatic = is_dev_package(root) || root === SESSION_ENTRY_ROOT[] + if automatic && root ∉ SESSION_UNFOCUSED + push!(FOCUS[], root) + else + delete!(FOCUS[], root) + end + end + invalidate_policy_cache!() + return nothing +end + +""" + Debugger.interpreted_modules() -> Vector{Module} + +Return a snapshot of the current focus set: the root modules mixed mode interprets. +Everything else runs natively when no argument carries focus code. See +[`interpret_module!`](@ref), [`compile_module!`](@ref) and the `focus` debugger +command (mutating the returned vector has no effect). +""" +interpreted_modules() = sort!(collect(focus_modules()); by = m -> string(nameof(m))) + +# ------------------------------------------------------------------ +# "Does this value carry focus code?" — classification of runtime values +# ------------------------------------------------------------------ + +# Constructors must be classified by the type they construct, not by +# `typeof(MyT) === DataType` (which would classify them as Core). +classify(@nospecialize x) = x isa Type ? x : typeof(x) + +const _usercode_cache = IdDict{Any,Bool}() + +function invalidate_policy_cache!() + empty!(_usercode_cache) + empty!(_revdep_cache) + _bp_suppression_computed[] = false + _suppression_warned[] = false + _compiled_path_prefixes[] = nothing + _compiled_source_basenames[] = nothing + return nothing +end + +""" + carries_focus_code(x) -> Bool + +`true` if the runtime value `x` is, or (recursively through its type parameters) +mentions, a function or type from a module in the focus set. Focus `Module`s and +`GlobalRef`s into them also count: their types belong to `Core`, but a callee can +resolve and call focus code through them. +""" +function carries_focus_code(@nospecialize(x)) + x isa Module && return is_focus_module(Base.moduleroot(x)) + x isa GlobalRef && return is_focus_module(Base.moduleroot(x.mod)) + x isa TypeVar && return _carries_user_code(x) + x isa Core.TypeofVararg && return _carries_user_code(x) # `classify` would erase it + return _carries_user_code(classify(x)) +end + +function _carries_user_code(@nospecialize(t)) + r = get(_usercode_cache, t, nothing) + r !== nothing && return r::Bool + sawcycle = Ref(false) + result = _usercode_walk(t, Base.IdSet{Any}(), sawcycle) + # A result computed under an "assume boring" cycle assumption is not safe to memoize. + sawcycle[] || (_usercode_cache[t] = result) + return result +end + +function _usercode_walk(@nospecialize(t), inprogress, sawcycle) + if t isa TypeVar + return _usercode_walk(t.ub, inprogress, sawcycle) || + _usercode_walk(t.lb, inprogress, sawcycle) + elseif t isa Union + return _usercode_walk(t.a, inprogress, sawcycle) || + _usercode_walk(t.b, inprogress, sawcycle) + elseif t isa UnionAll + return _usercode_walk(t.var, inprogress, sawcycle) || + _usercode_walk(t.body, inprogress, sawcycle) + elseif t isa Core.TypeofVararg + return (isdefined(t, :T) && _usercode_walk(t.T, inprogress, sawcycle)) || + (isdefined(t, :N) && _usercode_walk_param(t.N, inprogress, sawcycle)) + elseif t isa DataType + if t ∈ inprogress + sawcycle[] = true + return false + end + r = get(_usercode_cache, t, nothing) + r !== nothing && return r::Bool + is_compiled_module(Base.moduleroot(t.name.module)) || return true + push!(inprogress, t) + try + for p in t.parameters + _usercode_walk_param(p, inprogress, sawcycle) && return true + end + finally + delete!(inprogress, t) + end + return false + end + return false # Union{}, Module, etc. +end + +# Type parameters can be types or values; values can be types/modules/tuples that +# carry focus identity their `typeof` would erase (e.g. `Val{(MyT,)}` has the value +# parameter `(MyT,)` whose type is just `Tuple{DataType}`). +function _usercode_walk_param(@nospecialize(p), inprogress, sawcycle) + if p isa Type || p isa TypeVar || p isa Core.TypeofVararg + return _usercode_walk(p, inprogress, sawcycle) + elseif p isa Module + return !is_compiled_module(Base.moduleroot(p)) + elseif p isa Tuple + return any(x -> _usercode_walk_param(x, inprogress, sawcycle), p) + else + return _usercode_walk(typeof(p), inprogress, sawcycle) + end +end + +# ------------------------------------------------------------------ +# Breakpoint-based suppression of the fast path +# ------------------------------------------------------------------ + +# Native calls create no frames, so a breakpoint inside compiled territory could be +# reached from *any* native call (e.g. LinearAlgebra natively calling into Base). +# The only sound cheap answer is a global one: if any enabled breakpoint could live +# in a compiled module, mixed mode suppresses native execution entirely. +# +# `nothing` means "needs recomputation"; invalidated by the breakpoint-update hook. +const _bp_suppression = Ref{Union{Nothing,String}}(nothing) +const _bp_suppression_computed = Ref(false) +const _suppression_warned = Ref(false) + +function _breakpoints_updated_hook(f, bp) + _bp_suppression[] = nothing + _bp_suppression_computed[] = false + _suppression_warned[] = false + return nothing +end + +_scope_root(scope::Method) = Base.moduleroot(scope.module) +_scope_root(scope::Module) = Base.moduleroot(scope) + +function _target_root(@nospecialize(f)) + f isa Method && return Base.moduleroot(f.module) + t = Base.unwrap_unionall(classify(f)) + t isa DataType && return Base.moduleroot(t.name.module) + return nothing +end + +# A breakpoint in a *focus* package is still not safe if a loaded non-focus package +# (transitively) depends on it: a native call into the latter can reach the focus +# package without any focus-typed argument (e.g. registered JuliaInterpreter calling +# a dev'ed CodeTracking). `identify_package(where, name)` answers "is `name` a +# declared dependency of `where`" from the loaded manifest, so walking dependents +# recursively covers chains like non-focus A → focus B → focus M. +const _revdep_cache = Dict{Module,Bool}() + +_has_nonfocus_reverse_dep(M::Module) = _revdep_risk(M, Set{Module}()) + +function _revdep_risk(M::Module, inprogress::Set{Module}) + r = get(_revdep_cache, M, nothing) + r !== nothing && return r::Bool + M ∈ inprogress && return false # package graphs are acyclic; guard regardless + M === Main && return false + pkgdir(M) === nothing && return false + target = Base.PkgId(M) + target.uuid === nothing && return false + name = String(nameof(M)) + push!(inprogress, M) + result = false + seen = Set{Module}() + for (_, m) in Base.loaded_modules + R = Base.moduleroot(m) + (R ∈ seen || R === M || R === Main) && continue + push!(seen, R) + pkgid = Base.PkgId(R) + pkgid.uuid === nothing && continue + Base.identify_package(pkgid, name) == target || continue + # R depends on M: native code can reach M if R itself is native (non-focus) + # or if native code can reach R + if !is_focus_module(R) || _revdep_risk(R, inprogress) + result = true + break + end + end + delete!(inprogress, M) + return _revdep_cache[M] = result +end + +# Source locations of the non-focus ("compiled") modules, used to decide whether a +# file breakpoint could match compiled code: Julia's base and stdlib sources plus +# the package directories of every loaded non-focus root. +const _compiled_path_prefixes = Ref{Union{Nothing,Vector{String}}}(nothing) +const _compiled_source_basenames = Ref{Union{Nothing,Set{String}}}(nothing) + +function _compiled_source_dirs() + dirs = String[normpath(joinpath(Sys.BINDIR, Base.DATAROOTDIR, "julia")), Sys.STDLIB] + seen = Set{Module}() + for (_, m) in Base.loaded_modules + root = Base.moduleroot(m) + root ∈ seen && continue + push!(seen, root) + # focus packages are also risky when a loaded non-focus package depends on them + is_focus_module(root) && !_has_nonfocus_reverse_dep(root) && continue + d = pkgdir(root) + d === nothing || push!(dirs, d) + end + return unique!(String[_maybe_realpath(d) for d in dirs]) +end + +function compiled_path_prefixes() + v = _compiled_path_prefixes[] + v === nothing || return v + return _compiled_path_prefixes[] = _compiled_source_dirs() +end + +function compiled_source_basenames() + v = _compiled_source_basenames[] + v === nothing || return v + names = Set{String}() + for dir in compiled_path_prefixes() + isdir(dir) || continue + for (_, _, files) in walkdir(dir; onerror = _ -> nothing) + for f in files + endswith(f, ".jl") && push!(names, f) + end + end + end + return _compiled_source_basenames[] = names +end + +# Relative file breakpoints match by path suffix against *any* source file (e.g. +# `"sort.jl"` also matches `base/sort.jl`), so only clearly-user paths avoid +# suppressing the fast path. +function _filebp_could_target_compiled(bp) + if isabspath(bp.path) + return any(pre -> _path_within(bp.abspath, pre), compiled_path_prefixes()) + else + return basename(bp.path) ∈ compiled_source_basenames() + end +end + +""" + native_suppressed_reason() -> Union{Nothing,String} + +Return the reason mixed mode is currently running fully interpreted, or `nothing` +if the fast path is active. +""" +function native_suppressed_reason() + # code may load packages mid-command (e.g. `using` from the evaluation prompt + # or inside interpreted code); the length check is a cheap no-op otherwise + refresh_loaded_modules!() + if JuliaInterpreter.break_on_error[] || JuliaInterpreter.break_on_throw[] + return "break on error/throw is enabled" + end + if !_bp_suppression_computed[] + _bp_suppression[] = _compute_bp_suppression() + _bp_suppression_computed[] = true + end + return _bp_suppression[] +end + +function _bp_target_suppression(root::Union{Module,Nothing}, bp) + root === nothing && return "breakpoint with unrecognized target: $bp" + if !is_focus_module(root) + return "breakpoint set outside the focus set: $bp" + elseif _has_nonfocus_reverse_dep(root) + return "breakpoint in $root, which loaded non-focus packages depend on: $bp" + end + return nothing +end + +function _compute_bp_suppression() + for bp in JuliaInterpreter.breakpoints() + bp.enabled[] || continue + for inst in bp.instances + reason = _bp_target_suppression(_scope_root(inst.framecode.scope), bp) + reason === nothing || return reason + end + if bp isa JuliaInterpreter.BreakpointSignature + reason = _bp_target_suppression(_target_root(bp.f), bp) + reason === nothing || return reason + elseif bp isa JuliaInterpreter.BreakpointFileLocation + # Instances only show where the breakpoint has matched *so far*; the + # path decides whether it could still match compiled code. + if _filebp_could_target_compiled(bp) + return "file breakpoint may match outside the focus set: $bp" + end + else + return "unrecognized breakpoint type: $(typeof(bp))" + end + end + return nothing +end + +# Focus growth on explicit step-in: landing in a dependency package means the user +# wants to debug it, so it joins the session focus set (announced). Base/stdlib +# frames are routinely passed through on the way into a user callback (e.g. the +# kwarg-body method of `sort(v; by = f)`), so those only get a once-per-module hint +# instead of silently disabling the fast path. +const _stdlib_focus_hinted = Set{Module}() + +function maybe_grow_focus!(state) + state.interp isa MixedInterpreter || return nothing + frame = state.frame + frame === nothing && return nothing + scope = JuliaInterpreter.scopeof(leaf(frame)) + mod = scope isa Method ? scope.module : scope + mod isa Module || return nothing + root = Base.moduleroot(mod) + is_focus_module(root) && return nothing + if is_stdlib_root(root) || root ∈ COMPILE_OVERRIDES || root ∈ SESSION_UNFOCUSED + # explicit exclusions win over automatic growth; stdlibs are only hinted + # because focusing e.g. Base would de-facto disable the fast path + if root ∉ _stdlib_focus_hinted + push!(_stdlib_focus_hinted, root) + printstyled(stderr, "stepped into $root (not added to the focus set; " * + "`focus add $(nameof(root))` to interpret it everywhere)\n"; color=:light_black) + end + else + focus_module!(root) + printstyled(stderr, "added $root to the focus set (it will now be interpreted); " * + "`focus rm $(nameof(root))` to undo\n"; color=:light_black) + end + return nothing +end + +# Called at the start of each stepping command. Recomputes the suppression state +# (the breakpoint-update hook does not fire for e.g. `JuliaInterpreter.remove()`) +# and prints, once per suppression period, why mixed mode is not fast right now. +function maybe_warn_native_suppressed(state) + state.interp isa MixedInterpreter || return nothing + _bp_suppression_computed[] = false + reason = native_suppressed_reason() + if reason === nothing + _suppression_warned[] = false + elseif !_suppression_warned[] + _suppression_warned[] = true + printstyled(stderr, "mixed mode running fully interpreted ($reason)\n"; color=:yellow) + end + return nothing +end + +# ------------------------------------------------------------------ +# The policy and the interpreter hook +# ------------------------------------------------------------------ + +function run_native(fargs::Vector{Any}, world::UInt=Base.get_world_counter()) + native_suppressed_reason() === nothing || return false + for x in fargs + carries_focus_code(x) && return false + end + # `JuliaInterpreter.interpreted_methods` has documented precedence over any + # compiled-mode mechanism. It is empty by default, so the method lookup below + # is not paid in the common case. + if !isempty(JuliaInterpreter.interpreted_methods) + sig = Tuple{Base.mapany(JuliaInterpreter._Typeof, fargs)...} + method = try + Base.invoke_in_world(world, which, sig) + catch + nothing + end + method !== nothing && method ∈ JuliaInterpreter.interpreted_methods && return false + end + return true +end + +function JuliaInterpreter.evaluate_call!(interp::MixedInterpreter, frame::Frame, + fargs::Vector{Any}, enter_generated::Bool) + f = fargs[1] + # `Core.eval`, `rethrow` and `Core.invoke` have dedicated handling in the generic + # method that must not be bypassed by a native call. + if f === Core.eval || f === Base.rethrow || f === Core.invoke || !run_native(fargs, frame.world) + return @invoke JuliaInterpreter.evaluate_call!(interp::Interpreter, frame::Frame, + fargs::Vector{Any}, enter_generated::Bool) + end + return JuliaInterpreter.native_call(fargs, frame) +end + +# ------------------------------------------------------------------ +# Modes +# ------------------------------------------------------------------ + +const DEFAULT_MODE = Ref{Symbol}(:mixed) + +function interp_for_mode(mode::Symbol) + mode === :interpreted && return RecursiveInterpreter() + mode === :mixed && return MixedInterpreter() + mode === :compiled && return NonRecursiveInterpreter() + throw(ArgumentError("unknown stepping mode :$mode (expected :interpreted, :mixed or :compiled)")) +end + +mode_of_interp(::RecursiveInterpreter) = :interpreted +mode_of_interp(::MixedInterpreter) = :mixed +mode_of_interp(::NonRecursiveInterpreter) = :compiled + +""" + Debugger.set_default_mode!(mode::Symbol) + +Set the stepping mode future debug sessions start in (suitable for `startup.jl`). +`mode` is one of: + +- `:mixed` (default): run Base/stdlib code natively unless the call can reach user + code (user functions or types among the arguments). Much faster than interpreting + everything; see the manual for the cases it cannot catch. +- `:interpreted`: interpret everything; breakpoints always work. +- `:compiled`: run everything below the current frame natively (like the `C` toggle). + +The mode of a running session is changed with the `mode` debugger command, or the +`M` (mixed) and `C` (compiled) keys. +""" +function set_default_mode!(mode::Symbol) + interp_for_mode(mode) # validate + DEFAULT_MODE[] = mode + return mode +end + +""" + Debugger.default_mode() -> Symbol + +The stepping mode new debug sessions start in. See [`set_default_mode!`](@ref). +""" +default_mode() = DEFAULT_MODE[] + +# Drop all session/policy state so it is recomputed from scratch. Used by the +# precompile workload (no policy state may be serialized into the pkgimage) and +# defensively at `__init__`. +function reset_mixed_mode_state!() + FOCUS[] = nothing + SESSION_ENTRY_ROOT[] = nothing + _pending_entry_hint[] = nothing + empty!(SESSION_UNFOCUSED) + empty!(_dev_package_cache) + empty!(_stdlib_focus_hinted) + _loaded_fingerprint[] = -1 + invalidate_policy_cache!() + return nothing +end + +function init_mixed_mode() + empty!(STDLIB_NAMES) + for name in readdir(Sys.STDLIB) + isdir(joinpath(Sys.STDLIB, name)) && push!(STDLIB_NAMES, Symbol(name)) + end + isdefined(Base, :Compiler) && (_compiler_root[] = Base.moduleroot(Base.Compiler::Module)) + reset_mixed_mode_state!() + JuliaInterpreter.on_breakpoints_updated(_breakpoints_updated_hook) + return nothing +end diff --git a/src/precompile.jl b/src/precompile.jl index 08c62ce..188be23 100644 --- a/src/precompile.jl +++ b/src/precompile.jl @@ -49,4 +49,8 @@ end empty!(WATCH_LIST) JuliaInterpreter.remove() _ALT_SCREEN[] = false + # The session ran under the mixed-mode default and populated the focus set + # and policy caches with references to the precompile process' modules; + # reset so nothing stale is serialized (reclassified lazily at runtime). + reset_mixed_mode_state!() end diff --git a/src/repl.jl b/src/repl.jl index 4e3c5ab..7b3c46f 100644 --- a/src/repl.jl +++ b/src/repl.jl @@ -1,6 +1,11 @@ promptname(level, name) = "$level|$name> " +# Mixed mode is the default, so it keeps the plain debug prompt (in the +# traditional orange); the other modes are called out by name and color. +mode_prompt_name(state) = state.interp isa NonRecursiveInterpreter ? "compiled" : + state.interp isa RecursiveInterpreter ? "interpreted" : "debug" + function write_prompt(terminal, mode) LineEdit.write_prompt(terminal, mode, LineEdit.hascolor(terminal)) end @@ -77,12 +82,16 @@ function RunDebugger(frame, repl = nothing, terminal = nothing; initial_continue terminal = Base.active_repl.t end state = DebuggerState(; frame=frame, repl=repl, terminal=terminal) + seed_focus!(frame) # Setup debug panel normal_prefix = Sys.iswindows() ? "\e[33m" : "\e[38;5;166m" compiled_prefix = "\e[96m" - panel = LineEdit.Prompt(promptname(state.level, "debug"); - prompt_prefix = () -> state.interp == NonRecursiveInterpreter() ? compiled_prefix : normal_prefix, + # bright magenta; green would be confusable with the julia> prompt + interpreted_prefix = "\e[95m" + panel = LineEdit.Prompt(promptname(state.level, mode_prompt_name(state)); + prompt_prefix = () -> state.interp isa NonRecursiveInterpreter ? compiled_prefix : + state.interp isa RecursiveInterpreter ? interpreted_prefix : normal_prefix, prompt_suffix = Base.text_colors[:normal], on_enter = s->true) @@ -98,7 +107,6 @@ function RunDebugger(frame, repl = nothing, terminal = nothing; initial_continue panel.on_done = (s,buf,ok)->begin line = String(take!(buf)) - old_level = state.level if !ok || strip(line) == "q" LineEdit.transition(s, :abort) LineEdit.reset_state(s) @@ -136,9 +144,8 @@ function RunDebugger(frame, repl = nothing, terminal = nothing; initial_continue LineEdit.reset_state(s) return false end - if old_level != state.level - panel.prompt = promptname(state.level, "debug") - end + # both the level and the stepping mode are part of the prompt + panel.prompt = promptname(state.level, mode_prompt_name(state)) LineEdit.reset_state(s) if state.frame === nothing LineEdit.transition(s, :abort) @@ -166,12 +173,29 @@ function RunDebugger(frame, repl = nothing, terminal = nothing; initial_continue 'C' => function (s, args...) if isempty(s) || position(LineEdit.buffer(s)) == 0 toggle_mode(state) - write(state.terminal, '\r') + panel.prompt = promptname(state.level, mode_prompt_name(state)) + io = output_stream(state) + println(io) + show_status(state) + printstyled(io, "Stepping mode: ", mode_description(state.interp), "\n"; color=:light_black) write_prompt(state.terminal, panel) else LineEdit.edit_insert(s, "C") end end, + 'M' => function (s, args...) + if isempty(s) || position(LineEdit.buffer(s)) == 0 + toggle_mixed(state) + panel.prompt = promptname(state.level, mode_prompt_name(state)) + io = output_stream(state) + println(io) + show_status(state) + printstyled(io, "Stepping mode: ", mode_description(state.interp), "\n"; color=:light_black) + write_prompt(state.terminal, panel) + else + LineEdit.edit_insert(s, "M") + end + end, 'L' => function (s, args...) if isempty(s) || position(LineEdit.buffer(s)) == 0 toggle_lowered(state) @@ -265,6 +289,9 @@ function RunDebugger(frame, repl = nothing, terminal = nothing; initial_continue JuliaInterpreter.maybe_next_call!(state.frame) end show_status(state) + # transient notices must come after the status draw, which erases + # everything below itself in sticky mode + maybe_print_entry_hint(state) prompts = LineEdit.TextInterface[panel] diff --git a/test/menus.jl b/test/menus.jl index 745d701..89a25c6 100644 --- a/test/menus.jl +++ b/test/menus.jl @@ -255,6 +255,53 @@ end end end +@testset "focus menu" begin + Debugger.FOCUS[] = nothing + Debugger.invalidate_policy_cache!() + try + # toggle Main off with space (row order depends on the environment, so + # navigate to it), back on with enter; the row stays for re-toggling + rows = Debugger.focus_menu_rows() + idx = findfirst(==(Main), rows) + state = menu_state(nothing, fill(:down, idx - 1)..., ' ', 'q') + quietly(() -> Debugger.focus_menu(state)) + @test Main ∉ Debugger.interpreted_modules() + rows = Debugger.focus_menu_rows() + idx = findfirst(==(Main), rows) + @test idx !== nothing # still shown, just toggled off + state = menu_state(nothing, fill(:down, idx - 1)..., :enter, 'q') + quietly(() -> Debugger.focus_menu(state)) + @test Main ∈ Debugger.interpreted_modules() + + # add a module from the menu: `a` prompts for a name and reopens + state = menu_state(nothing, 'a', "JuliaInterpreter\n", 'q') + quietly(() -> Debugger.focus_menu(state)) + @test JuliaInterpreter ∈ Debugger.interpreted_modules() + + # row rendering + buf = IOBuffer() + Debugger.focus_menu_writerow(IOContext(buf, :displaysize => (24, 80)), Main, 1) + @test occursin("Main", String(take!(buf))) + finally + Debugger.FOCUS[] = nothing + empty!(Debugger.SESSION_UNFOCUSED) + Debugger.invalidate_policy_cache!() + end +end + +@testset "focus command falls back to a text list without menus" begin + Debugger.config(menus = false) + try + state = menu_state(nothing) + execute_command(state, Val{:focus}(), "focus") + out = String(take!(state.terminal.out_stream)) + @test occursin("Focus set", out) + @test occursin("Main", out) + finally + Debugger.config(menus = true) + end +end + @testset "frameless bp commands (debug> mode)" begin JuliaInterpreter.remove() try diff --git a/test/mixed_mode.jl b/test/mixed_mode.jl new file mode 100644 index 0000000..4343bac --- /dev/null +++ b/test/mixed_mode.jl @@ -0,0 +1,380 @@ +module MixedModeTests + +using Debugger: Debugger, MixedInterpreter, run_native, carries_focus_code, + native_suppressed_reason, compile_module!, interpret_module!, + interpreted_modules, interp_for_mode, set_default_mode!, default_mode, + focus_module!, unfocus_module!, seed_focus!, is_focus_module +using JuliaInterpreter +using JuliaInterpreter: debug_command, BreakpointRef, @bp +import CodeTracking +import REPL +using Test + +struct MyT + x::Int +end +struct MyCallable end +(::MyCallable)(x) = x + 1 +user_f(x) = 2x + +inner(x) = x * 2 +map_entry(xs) = sum(map(inner, xs)) + length(xs) +sortby_entry(xs) = sort(xs; by = inner) +bcast_entry(xs) = inner.(xs) +doblock_entry() = sprint(io -> print(io, inner(3))) +splat_entry(xs) = inner(xs...) +gcd_entry() = gcd(10, 20) + 0 +sum_entry(x) = sum(abs2, 1:x) + 1 +catcher() = try + sqrt(-1.0) +catch + 42 +end +bp_cb(x) = (@bp; x + 1) +bp_entry(xs) = sum(map(bp_cb, xs)) +const ji_breakpoints = JuliaInterpreter.breakpoints +ji_entry() = ji_breakpoints() isa Vector +gcd_step_entry() = gcd(4, 6) > 0 + +mkstate(frame) = Debugger.DebuggerState(frame = frame) + +fresh_suppression!() = (Debugger._bp_suppression_computed[] = false) + +@testset "policy: carries_focus_code" begin + @test !carries_focus_code(sin) + @test !carries_focus_code([1, 2, 3]) + @test !carries_focus_code("abc") + @test !carries_focus_code(Union{Int, Missing}[]) + @test !carries_focus_code(AbstractVector) + @test carries_focus_code(user_f) + @test carries_focus_code(MyT) # constructors are not DataType/Core + @test carries_focus_code(MyCallable()) # callable struct + @test carries_focus_code(MyT[]) # user type as type parameter + @test carries_focus_code(AbstractVector{MyT}) + @test carries_focus_code(Base.Generator(user_f, [1])) + @test carries_focus_code((by = user_f,)) # keyword-call NamedTuple + @test carries_focus_code(Val(user_f)) # value type parameter + @test carries_focus_code(Val((user_f,))) # focus function inside a tuple value parameter + @test !carries_focus_code(Val((1, 2))) + @test carries_focus_code(TypeVar(:T, Union{}, MyT)) # TypeVar value with focus bound + @test carries_focus_code(Vararg{MyT}) # top-level Vararg value + @test !carries_focus_code(Vararg{Int}) + # Registered dependencies are not in focus; a devved JuliaInterpreter is. + ji_focused = is_focus_module(Base.moduleroot(JuliaInterpreter)) + @test carries_focus_code(JuliaInterpreter.breakpoints) == ji_focused + @test carries_focus_code(JuliaInterpreter.BreakpointRef[]) == ji_focused + # modules and GlobalRefs can smuggle focus code without focus-typed args + @test carries_focus_code(Main) + @test carries_focus_code(@__MODULE__) + @test !carries_focus_code(Base) + @test carries_focus_code(GlobalRef(Main, :x)) + @test !carries_focus_code(GlobalRef(Base, :sin)) +end + +@testset "path containment" begin + @test Debugger._path_within("/a/packages/X", "/a/packages") + @test Debugger._path_within("/a/packages", "/a/packages") + @test !Debugger._path_within("/a/packages-dev/X", "/a/packages") +end + +@testset "policy: run_native" begin + @test run_native(Any[sort, [3, 1, 2]]) + @test !run_native(Any[map, user_f, [1, 2]]) + @test !run_native(Any[MyT, 1]) + @test !run_native(Any[Core.kwcall, (by = user_f,), sort, [3, 1, 2]]) + # Registered dependencies run natively; devved dependencies stay interpretable. + ji_focused = is_focus_module(Base.moduleroot(JuliaInterpreter)) + @test run_native(Any[JuliaInterpreter.breakpoints]) == !ji_focused + @test run_native(Any[map, JuliaInterpreter.leaf, JuliaInterpreter.Frame[]]) == !ji_focused + # documented limitation: type-erased containers hide user code + @test run_native(Any[foreach, print, Any[user_f]]) +end + +@testset "focus set" begin + mods = interpreted_modules() + @test Main ∈ mods # test module's root is Main + @test Base ∉ mods && Core ∉ mods + ji_is_dev = Debugger.is_dev_package(JuliaInterpreter) + @test (JuliaInterpreter ∈ mods) == ji_is_dev + # under PkgEval Debugger itself is a registered install, so compare rather than assert + @test (Debugger ∈ mods) == Debugger.is_dev_package(Debugger) + + # session-scoped add/remove + unfocus_module!(JuliaInterpreter) + @test focus_module!(JuliaInterpreter) + @test JuliaInterpreter ∈ interpreted_modules() + @test !run_native(Any[JuliaInterpreter.breakpoints]) + @test unfocus_module!(JuliaInterpreter) + @test run_native(Any[JuliaInterpreter.breakpoints]) + + # permanent overrides + try + compile_module!(@__MODULE__) # root is Main + @test run_native(Any[user_f, 1]) + interpret_module!(@__MODULE__) + @test !run_native(Any[user_f, 1]) + finally + delete!(Debugger.COMPILE_OVERRIDES, Base.moduleroot(@__MODULE__)) + delete!(Debugger.INTERPRET_OVERRIDES, Base.moduleroot(@__MODULE__)) + Debugger.FOCUS[] = nothing + Debugger.invalidate_policy_cache!() + end + @test !run_native(Any[user_f, 1]) +end + +@testset "focus seeding" begin + # entering a dependency function focuses its package + frame = JuliaInterpreter.enter_call(CodeTracking.maybe_fix_path, "/no/such/path.jl") + seed_focus!(frame) + @test CodeTracking ∈ interpreted_modules() + # entering a Base function does not drag Base into focus, but it is + # remembered (hint printed once) and offered as a row in the focus menu. + # The hint is once-per-process; earlier tests may have stepped into Base + delete!(Debugger._stdlib_focus_hinted, Base) + Debugger._pending_entry_hint[] = nothing + frame = JuliaInterpreter.enter_call(gcd, 10, 20) + seed_focus!(frame) + @test Base ∉ interpreted_modules() + @test CodeTracking ∉ interpreted_modules() # reseed dropped the previous entry root + @test Main ∈ interpreted_modules() + @test Base ∈ Debugger._stdlib_focus_hinted + @test Base ∈ Debugger.focus_menu_rows() + # the hint is stashed at seed time (printing there would be swallowed by + # the alternate-screen switch) and printed after the first status draw + @test Debugger._pending_entry_hint[] === Base + term = REPL.Terminals.TTYTerminal("xterm", IOBuffer(), IOBuffer(), IOBuffer()) + st = Debugger.DebuggerState(frame = nothing, terminal = term) + Debugger.maybe_print_entry_hint(st) + @test occursin("focus add Base", String(take!(term.out_stream))) + @test Debugger._pending_entry_hint[] === nothing +end + +@testset "JuliaInterpreter.interpreted_methods precedence" begin + m = which(sort, (Vector{Int},)) + push!(JuliaInterpreter.interpreted_methods, m) + try + @test !run_native(Any[sort, [3, 1, 2]]) + finally + delete!(JuliaInterpreter.interpreted_methods, m) + end + @test run_native(Any[sort, [3, 1, 2]]) +end + +@testset "breakpoint suppression" begin + JuliaInterpreter.remove(); fresh_suppression!() + @test native_suppressed_reason() === nothing + + # breakpoints in user code do not suppress the fast path + bp = JuliaInterpreter.breakpoint(inner); fresh_suppression!() + @test native_suppressed_reason() === nothing + JuliaInterpreter.remove(bp) + bp = JuliaInterpreter.breakpoint(MyT); fresh_suppression!() + @test native_suppressed_reason() === nothing + JuliaInterpreter.remove(bp) + + # breakpoints in compiled modules suppress it + bp = JuliaInterpreter.breakpoint(sort); fresh_suppression!() + @test native_suppressed_reason() !== nothing + @test !run_native(Any[sort, [3, 1, 2]]) + JuliaInterpreter.remove(bp); fresh_suppression!() + @test native_suppressed_reason() === nothing + + # disabled breakpoints do not suppress + bp = JuliaInterpreter.breakpoint(sort) + JuliaInterpreter.disable(bp); fresh_suppression!() + @test native_suppressed_reason() === nothing + JuliaInterpreter.remove(bp) + + # break_on suppresses + JuliaInterpreter.break_on(:error) + @test native_suppressed_reason() !== nothing + JuliaInterpreter.break_off(:error); fresh_suppression!() + @test native_suppressed_reason() === nothing + + # a breakpoint in a focus package that loaded non-focus packages depend on + # also suppresses (native JuliaInterpreter code can reach CodeTracking) + @test Debugger._has_nonfocus_reverse_dep(CodeTracking) + @test !Debugger._has_nonfocus_reverse_dep(Debugger) + try + focus_module!(CodeTracking) + bp = JuliaInterpreter.breakpoint(CodeTracking.maybe_fix_path); fresh_suppression!() + @test occursin("depend on", something(native_suppressed_reason(), "")) + JuliaInterpreter.remove(bp) + finally + unfocus_module!(CodeTracking) + empty!(Debugger.SESSION_UNFOCUSED) + fresh_suppression!() + end + @test native_suppressed_reason() === nothing +end + +@testset "reset_module!" begin + try + compile_module!(@__MODULE__) # root is Main + @test Main ∉ interpreted_modules() + focus_module!(JuliaInterpreter) # unrelated session choice + Debugger.reset_module!(@__MODULE__) + @test Main ∈ interpreted_modules() + @test JuliaInterpreter ∈ interpreted_modules() # survived the reset + finally + delete!(Debugger.COMPILE_OVERRIDES, Main) + Debugger.FOCUS[] = nothing + empty!(Debugger.SESSION_UNFOCUSED) + Debugger.invalidate_policy_cache!() + end +end + +@testset "julia internals are not auto-focused" begin + if isdefined(Base, :Compiler) + @test Debugger.is_stdlib_root(Base.moduleroot(Base.Compiler)) + end + # ...but a user module that happens to be named Compiler is + @test !Debugger.is_stdlib_root(Module(:Compiler)) +end + +@testset "reset_module! keeps the entered package focused" begin + try + frame = JuliaInterpreter.enter_call(CodeTracking.maybe_fix_path, "/no/such/path.jl") + seed_focus!(frame) + @test CodeTracking ∈ interpreted_modules() # focused as the entry root + compile_module!(CodeTracking) + @test CodeTracking ∉ interpreted_modules() + Debugger.reset_module!(CodeTracking) + @test CodeTracking ∈ interpreted_modules() # entry-root focus restored + + # same but with the override in place before the session starts + compile_module!(CodeTracking) + frame = JuliaInterpreter.enter_call(CodeTracking.maybe_fix_path, "/no/such/path.jl") + seed_focus!(frame) + @test CodeTracking ∉ interpreted_modules() # override wins at seed time + Debugger.reset_module!(CodeTracking) + @test CodeTracking ∈ interpreted_modules() # entry root remembered anyway + finally + delete!(Debugger.COMPILE_OVERRIDES, CodeTracking) + Debugger.SESSION_ENTRY_ROOT[] = nothing + Debugger.FOCUS[] = nothing + Debugger.invalidate_policy_cache!() + end +end + +@testset "file breakpoint path classification" begin + mkbp(path) = JuliaInterpreter.BreakpointFileLocation( + path, CodeTracking.maybe_fix_path(abspath(path)), 1, nothing, Ref(true), BreakpointRef[]) + # relative paths match by suffix against any file, including Base sources + @test Debugger._filebp_could_target_compiled(mkbp("sort.jl")) + @test !Debugger._filebp_could_target_compiled(mkbp("surely_not_a_base_file.jl")) + base_sort = joinpath(Sys.BINDIR, Base.DATAROOTDIR, "julia", "base", "sort.jl") + @test Debugger._filebp_could_target_compiled(mkbp(base_sort)) + userfile = joinpath(mktempdir(), "userscript.jl") + write(userfile, "f() = 1\n") + @test !Debugger._filebp_could_target_compiled(mkbp(userfile)) + + bp = JuliaInterpreter.breakpoint(userfile, 1); fresh_suppression!() + @test native_suppressed_reason() === nothing + JuliaInterpreter.remove(bp) +end + +function break_leaf_scope(entry, args...) + frame = JuliaInterpreter.enter_call(entry, args...) + ret = debug_command(MixedInterpreter(), frame, :c) + ret === nothing && return nothing + frame2, pc = ret + pc isa BreakpointRef || return nothing + return JuliaInterpreter.scopeof(JuliaInterpreter.leaf(frame2)).name +end + +@testset "breakpoints in user callbacks fire through Base higher-order functions" begin + JuliaInterpreter.breakpoint(inner) + try + @test break_leaf_scope(map_entry, [1, 2, 3]) === :inner + @test break_leaf_scope(sortby_entry, [3, 1, 2]) === :inner + @test break_leaf_scope(bcast_entry, [1, 2, 3]) === :inner + @test break_leaf_scope(doblock_entry) === :inner + @test break_leaf_scope(splat_entry, (3,)) === :inner + finally + JuliaInterpreter.remove() + fresh_suppression!() + end +end + +@testset "breakpoint in Base fires because suppression interprets everything" begin + JuliaInterpreter.breakpoint(gcd) + try + @test break_leaf_scope(gcd_entry) === :gcd + finally + JuliaInterpreter.remove() + fresh_suppression!() + end +end + +@testset "native execution is correct" begin + frame = JuliaInterpreter.enter_call(sum_entry, 10) + @test debug_command(MixedInterpreter(), frame, :finish) === nothing + @test JuliaInterpreter.get_return(frame) == sum(abs2, 1:10) + 1 + + # an error thrown inside a native call is caught by the interpreted caller + frame = JuliaInterpreter.enter_call(catcher) + @test debug_command(MixedInterpreter(), frame, :finish) === nothing + @test JuliaInterpreter.get_return(frame) == 42 +end + +@testset "@bp fires through Base higher-order functions" begin + frame = JuliaInterpreter.enter_call(bp_entry, [1, 2]) + ret = debug_command(MixedInterpreter(), frame, :c) + @test ret !== nothing && ret[2] isa BreakpointRef + @test JuliaInterpreter.scopeof(JuliaInterpreter.leaf(ret[1])).name === :bp_cb +end + +@testset "focus grows on step-in" begin + Debugger.FOCUS[] = nothing + Debugger.invalidate_policy_cache!() + try + # stepping into a dependency package adds it to the session focus set + state = mkstate(JuliaInterpreter.enter_call(ji_entry)) + @test state.interp isa MixedInterpreter + Debugger.execute_command(state, Val{:s}(), "s") + @test JuliaInterpreter ∈ interpreted_modules() + # stepping into Base does not + state = mkstate(JuliaInterpreter.enter_call(gcd_step_entry)) + Debugger.execute_command(state, Val{:s}(), "s") + @test Base ∉ interpreted_modules() + finally + Debugger.FOCUS[] = nothing + Debugger.invalidate_policy_cache!() + end +end + +@testset "modes" begin + @test default_mode() === :mixed + @test interp_for_mode(:interpreted) isa JuliaInterpreter.RecursiveInterpreter + @test interp_for_mode(:mixed) isa MixedInterpreter + @test interp_for_mode(:compiled) isa JuliaInterpreter.NonRecursiveInterpreter + @test_throws ArgumentError interp_for_mode(:bogus) + @test_throws ArgumentError set_default_mode!(:bogus) + state = Debugger.DebuggerState(frame = nothing) + @test state.interp isa MixedInterpreter + try + set_default_mode!(:interpreted) + state = Debugger.DebuggerState(frame = nothing) + @test state.interp isa JuliaInterpreter.RecursiveInterpreter + finally + set_default_mode!(:mixed) + end + # M toggles interpreted and back; C toggles compiled and back + state = Debugger.DebuggerState(frame = nothing) + Debugger.toggle_mixed(state) + @test state.interp isa JuliaInterpreter.RecursiveInterpreter + Debugger.toggle_mixed(state) + @test state.interp isa MixedInterpreter + Debugger.toggle_mode(state) + @test state.interp isa JuliaInterpreter.NonRecursiveInterpreter + Debugger.toggle_mode(state) + @test state.interp isa MixedInterpreter + # `mode compiled` followed by `C` returns to the previous mode too + Debugger.set_session_mode!(state, :compiled) + @test state.interp isa JuliaInterpreter.NonRecursiveInterpreter + Debugger.toggle_mode(state) + @test state.interp isa MixedInterpreter +end + +end # module MixedModeTests diff --git a/test/runtests.jl b/test/runtests.jl index 34d9988..1dc88c2 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -20,5 +20,6 @@ Debugger.set_highlight(false) include("stepping.jl") include("interpret.jl") include("menus.jl") + include("mixed_mode.jl") include("ui.jl") #end diff --git a/test/ui.jl b/test/ui.jl index f52d0e8..546f6a1 100644 --- a/test/ui.jl +++ b/test/ui.jl @@ -105,7 +105,11 @@ end print_full_path = Debugger._print_full_path[] source_lines = Debugger.NUM_SOURCE_LINES_UP_DOWN[] watch_list = copy(Debugger.WATCH_LIST) + saved_mode = Debugger.default_mode() Debugger._print_full_path[] = false + # the recorded terminal sessions were captured in mixed mode (the default); + # pin it in case the process default was changed + Debugger.set_default_mode!(:mixed) try using TerminalRegressionTests @@ -232,6 +236,7 @@ end Debugger.NUM_SOURCE_LINES_UP_DOWN[] = source_lines empty!(Debugger.WATCH_LIST) append!(Debugger.WATCH_LIST, watch_list) + Debugger.set_default_mode!(saved_mode) end else @warn "Skipping UI tests" diff --git a/test/ui/history_gcd.multiout b/test/ui/history_gcd.multiout index e93b45b..813ca80 100644 --- a/test/ui/history_gcd.multiout +++ b/test/ui/history_gcd.multiout @@ -2843,6 +2843,20 @@ | |→ (===)(20, 0) |1|debug> +|[1/2] ==(x, y) at promotion.jl:637 +|>637 (==)(x::T, y::T) where {T<:Number} = x === y +| +| x::Int64 = 20 (arg) +| y::Int64 = 0 (arg) +| T = Int64 (param) +| +| w1] sin(a): UndefVarError: `a` not defined in `Base` +| w2] b: UndefVarError: `b` not defined in `Base` +| +|→ (===)(20, 0) +|Stepping mode: compiled (stepped-over code runs natively, breakpoints in it are +|missed) +|1|compiled> -------------------------------------------------- |AAAAAABBBBBBBBBBBBCCCCCCCCCCC |CCCCBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB @@ -2975,7 +2989,21 @@ |CCCCCCBBBHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH | |EEBBBBBBBBBBBB -|IIIIIIIII +|FFFFFFFFF +|AAAAAABBBBBBBBCCCCCCCCCCCCCCCCCCCC +|DDDDDDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +| +|BBBBBBBBBBBBBBBCCCCCCC +|BBBBBBBBBBBBBBCCCCCCC +|BBBBBBBBBBBBBBBBBBCCCCCCCCC +| +|CCCCCCBBBBBBBBHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH +|CCCCCCBBBHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH +| +|EEBBBBBBBBBBBB +|CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC +|CCCCCCC +|IIIIIIIIIIII ++++++++++++++++++++++++++++++++++++++++++++++++++ |[1/1] my_gcd(a, b) at ui.jl:5 | 4 function my_gcd(a::T, b::T) where T<:Union{Int8,UInt8,Int16,UInt16,Int32,UIn @@ -3108,7 +3136,21 @@ | w2] b: UndefVarError: `b` not defined in `Base` | |→ (===)(20, 0) -|1|debug> c +|1|debug> +|[1/2] ==(x, y) at promotion.jl:637 +|>637 (==)(x::T, y::T) where {T<:Number} = x === y +| +| x::Int64 = 20 (arg) +| y::Int64 = 0 (arg) +| T = Int64 (param) +| +| w1] sin(a): UndefVarError: `a` not defined in `Base` +| w2] b: UndefVarError: `b` not defined in `Base` +| +|→ (===)(20, 0) +|Stepping mode: compiled (stepped-over code runs natively, breakpoints in it are +|missed) +|1|compiled> c |Hit breakpoint: |[1/1] my_gcd(a, b) at ui.jl:13 | 9 k = min(za, zb) @@ -3134,7 +3176,7 @@ | w2] b: 20 | |→ (!=)(0x0000000000000005, 0x0000000000000005) -|1|debug> +|1|compiled> -------------------------------------------------- |AAAAAABBBBBBBBBBBBCCCCCCCCCCC |CCCCBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB @@ -3267,7 +3309,21 @@ |CCCCCCBBBHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH | |EEBBBBBBBBBBBB -|IIIIIIIIIB +|FFFFFFFFF +|AAAAAABBBBBBBBCCCCCCCCCCCCCCCCCCCC +|DDDDDDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +| +|BBBBBBBBBBBBBBBCCCCCCC +|BBBBBBBBBBBBBBCCCCCCC +|BBBBBBBBBBBBBBBBBBCCCCCCCCC +| +|CCCCCCBBBBBBBBHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH +|CCCCCCBBBHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH +| +|EEBBBBBBBBBBBB +|CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC +|CCCCCCC +|IIIIIIIIIIIIB |BBBBBBBBBBBBBBB |AAAAAABBBBBBBBBBBBCCCCCCCCCCCC |CCCCCBBBBBBBBBBBBBBB @@ -3293,7 +3349,7 @@ |CCCCCCBBBBB | |EEBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB -|IIIIIIIII +|IIIIIIIIIIII ++++++++++++++++++++++++++++++++++++++++++++++++++ |[1/1] my_gcd(a, b) at ui.jl:5 | 4 function my_gcd(a::T, b::T) where T<:Union{Int8,UInt8,Int16,UInt16,Int32,UIn @@ -3426,7 +3482,21 @@ | w2] b: UndefVarError: `b` not defined in `Base` | |→ (===)(20, 0) -|1|debug> c +|1|debug> +|[1/2] ==(x, y) at promotion.jl:637 +|>637 (==)(x::T, y::T) where {T<:Number} = x === y +| +| x::Int64 = 20 (arg) +| y::Int64 = 0 (arg) +| T = Int64 (param) +| +| w1] sin(a): UndefVarError: `a` not defined in `Base` +| w2] b: UndefVarError: `b` not defined in `Base` +| +|→ (===)(20, 0) +|Stepping mode: compiled (stepped-over code runs natively, breakpoints in it are +|missed) +|1|compiled> c |Hit breakpoint: |[1/1] my_gcd(a, b) at ui.jl:13 | 9 k = min(za, zb) @@ -3452,6 +3522,33 @@ | w2] b: 20 | |→ (!=)(0x0000000000000005, 0x0000000000000005) +|1|compiled> +|[1/1] my_gcd(a, b) at ui.jl:13 +| 9 k = min(za, zb) +| 10 u = unsigned(abs(a >> za)) +| 11 v = unsigned(abs(b >> zb)) +|●12 Debugger.@bp +|>13 while u != v +| 14 if u > v +| 15 u, v = v, u +| 16 end +| 17 v -= u +| +| a::Int64 = 10 (arg) +| b::Int64 = 20 (arg) +| T = Int64 (param) +| v::UInt64 = 0x0000000000000005 +| u::UInt64 = 0x0000000000000005 +| k::Int64 = 1 +| zb::Int64 = 2 +| za::Int64 = 1 +| +| w1] sin(a): -0.5440211108893698 +| w2] b: 20 +| +|→ (!=)(0x0000000000000005, 0x0000000000000005) +|Stepping mode: mixed (code outside the focus modules runs natively unless it can +| reach them) |1|debug> -------------------------------------------------- |AAAAAABBBBBBBBBBBBCCCCCCCCCCC @@ -3585,8 +3682,47 @@ |CCCCCCBBBHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH | |EEBBBBBBBBBBBB -|IIIIIIIIIB +|FFFFFFFFF +|AAAAAABBBBBBBBCCCCCCCCCCCCCCCCCCCC +|DDDDDDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +| +|BBBBBBBBBBBBBBBCCCCCCC +|BBBBBBBBBBBBBBCCCCCCC +|BBBBBBBBBBBBBBBBBBCCCCCCCCC +| +|CCCCCCBBBBBBBBHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH +|CCCCCCBBBHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH +| +|EEBBBBBBBBBBBB +|CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC +|CCCCCCC +|IIIIIIIIIIIIB +|BBBBBBBBBBBBBBB +|AAAAAABBBBBBBBBBBBCCCCCCCCCCCC +|CCCCCBBBBBBBBBBBBBBB +|CCCCCBBBBBBBBBBBBBBBBBBBBBBBBBB +|CCCCCBBBBBBBBBBBBBBBBBBBBBBBBBB +|HHHHHBBBBBBBBBBBB +|DDDDDAAAAAAAAAAAA +|CCCCCBBBBBBBBBBBB +|CCCCCBBBBBBBBBBBBBBBBBBB +|CCCCCBBBBBBB +|CCCCCBBBBBBBBBB +| +|BBBBBBBBBBBBBBBBCCCCCCC +|BBBBBBBBBBBBBBBBCCCCCCC +|BBBBBBBBBBBBBBBBBBBCCCCCCCCC +|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB +|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB |BBBBBBBBBBBBBBB +|BBBBBBBBBBBBBBB +|BBBBBBBBBBBBBBB +| +|CCCCCCBBBBBBBBBBBBBBBBBBBBBBBBBBB +|CCCCCCBBBBB +| +|EEBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB +|IIIIIIIIIIII |AAAAAABBBBBBBBBBBBCCCCCCCCCCCC |CCCCCBBBBBBBBBBBBBBB |CCCCCBBBBBBBBBBBBBBBBBBBBBBBBBB @@ -3611,4 +3747,6 @@ |CCCCCCBBBBB | |EEBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB +|CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC +|CCCCCCCCCCCC |FFFFFFFFF \ No newline at end of file