From 28de664f566521b151d72ae60389b341ae4c2540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Samson?= Date: Tue, 29 Aug 2023 22:39:38 +0200 Subject: [PATCH] Dbg backend for debugger (#974) * add dbg backend * break on dbg * continue * stop session * properly get module code * overwrite bindings * step through pipes * pass env to eval * add tests * continue dbg session * fix tests on < 1.14 * fixing tests * remove not needed asserts * make dbg breakpoints work when debugging in non interpreted mode * port bugfix from elixir * get stacktrace from Process.info if not in interpreted mode * remove not relevant todos * fix breakpoint type detection * make sure process is dead --- apps/elixir_ls_debugger/lib/debugger.ex | 16 +- .../elixir_ls_debugger/lib/debugger/server.ex | 415 +++++++++++-- .../lib/debugger/stacktrace.ex | 32 +- .../elixir_ls_debugger/test/debugger_test.exs | 553 +++++++++++++++++- .../test/fixtures/mix_project/lib/dbg.ex | 21 + 5 files changed, 969 insertions(+), 68 deletions(-) create mode 100644 apps/elixir_ls_debugger/test/fixtures/mix_project/lib/dbg.ex diff --git a/apps/elixir_ls_debugger/lib/debugger.ex b/apps/elixir_ls_debugger/lib/debugger.ex index caf44f24f..85e640155 100644 --- a/apps/elixir_ls_debugger/lib/debugger.ex +++ b/apps/elixir_ls_debugger/lib/debugger.ex @@ -5,6 +5,7 @@ defmodule ElixirLS.Debugger do use Application alias ElixirLS.Debugger.Output + alias ElixirLS.Debugger.Server @impl Application def start(_type, _args) do @@ -12,9 +13,18 @@ defmodule ElixirLS.Debugger do # this process to remain alive to print errors {:ok, _pid} = Output.start(Output) - children = [ - {ElixirLS.Debugger.Server, name: ElixirLS.Debugger.Server} - ] + if Version.match?(System.version(), ">= 1.14.0") do + Application.put_env(:elixir, :dbg_callback, {Server, :dbg, []}) + end + + children = + if Mix.env() != :test do + [ + {Server, name: Server} + ] + else + [] + end opts = [strategy: :one_for_one, name: ElixirLS.Debugger.Supervisor, max_restarts: 0] Supervisor.start_link(children, opts) diff --git a/apps/elixir_ls_debugger/lib/debugger/server.ex b/apps/elixir_ls_debugger/lib/debugger/server.ex index ecdc92a3f..e9e439f6d 100644 --- a/apps/elixir_ls_debugger/lib/debugger/server.ex +++ b/apps/elixir_ls_debugger/lib/debugger/server.ex @@ -36,6 +36,7 @@ defmodule ElixirLS.Debugger.Server do defstruct client_info: nil, config: %{}, + dbg_session: nil, task_ref: nil, update_threads_ref: nil, thread_ids_to_pids: %{}, @@ -80,6 +81,95 @@ defmodule ElixirLS.Debugger.Server do GenServer.cast(server, {:paused, pid}) end + @spec dbg(Macro.t(), Macro.t(), Macro.Env.t()) :: Macro.t() + def dbg({:|>, _meta, _args} = ast, options, %Macro.Env{} = env) when is_list(options) do + [first_ast_chunk | asts_chunks] = ast |> Macro.unpipe() |> chunk_pipeline_asts_by_line(env) + + initial_acc = [ + quote do + env = __ENV__ + options = unquote(options) + + options = + if IO.ANSI.enabled?() do + Keyword.put_new(options, :syntax_colors, IO.ANSI.syntax_colors()) + else + options + end + + env = unquote(env_with_line_from_asts(first_ast_chunk)) + + next? = unquote(__MODULE__).pry_with_next(true, binding(), env) + value = unquote(pipe_chunk_of_asts(first_ast_chunk)) + + unquote(__MODULE__).__dbg_pipe_step__( + value, + unquote(asts_chunk_to_strings(first_ast_chunk)), + _start_with_pipe? = false, + options + ) + end + ] + + for asts_chunk <- asts_chunks, reduce: initial_acc do + ast_acc -> + piped_asts = pipe_chunk_of_asts([{quote(do: value), _index = 0}] ++ asts_chunk) + + quote do + unquote(ast_acc) + env = unquote(env_with_line_from_asts(asts_chunk)) + next? = unquote(__MODULE__).pry_with_next(next?, binding(), env) + value = unquote(piped_asts) + + unquote(__MODULE__).__dbg_pipe_step__( + value, + unquote(asts_chunk_to_strings(asts_chunk)), + _start_with_pipe? = true, + options + ) + end + end + end + + def dbg(code, options, %Macro.Env{} = caller) do + quote do + {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace) + GenServer.call(unquote(__MODULE__), {:dbg, binding(), __ENV__, stacktrace}, :infinity) + unquote(Macro.dbg(code, options, caller)) + end + end + + def pry_with_next(next?, binding, opts_or_env) when is_boolean(next?) do + if next? do + {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace) + + GenServer.call(__MODULE__, {:dbg, binding, opts_or_env, stacktrace}, :infinity) == + {:ok, true} + else + false + end + end + + @elixir_internals [:elixir, :erl_eval] + @elixir_ls_internals [__MODULE__] + @debugger_internals @elixir_internals ++ @elixir_ls_internals + + defp prune_stacktrace([{mod, _, _, _} | t]) when mod in @debugger_internals do + prune_stacktrace(t) + end + + defp prune_stacktrace([{Process, :info, 2, _} | t]) do + prune_stacktrace(t) + end + + defp prune_stacktrace([h | t]) do + [h | prune_stacktrace(t)] + end + + defp prune_stacktrace([]) do + [] + end + ## Server Callbacks @impl GenServer @@ -90,6 +180,87 @@ defmodule ElixirLS.Debugger.Server do {:ok, state} end + @impl GenServer + def handle_call({:dbg, binding, %Macro.Env{} = env, stacktrace}, from, state = %__MODULE__{}) do + {pid, _ref} = from + ref = Process.monitor(pid) + + {state, thread_id, _new_ids} = ensure_thread_id(state, pid, []) + + stacktrace = + case Stacktrace.get(pid) do + [_gen_server_frame, first_frame | stacktrace] -> + # drop GenServer call to Debugger.Server from dbg callback + # overwrite erlang debugger bindings with exact elixir ones + first_frame = %{ + first_frame + | bindings: Map.new(binding), + dbg_frame?: true, + dbg_env: Code.env_for_eval(env), + module: env.module, + function: env.function, + file: env.file, + line: env.line || 1 + } + + [first_frame | stacktrace] + + [] -> + # no stacktrace if we are running in non interpreted mode + # build frames from Process.info stacktrace + # drop first entry as we get it from env + [_ | stacktrace_rest] = prune_stacktrace(stacktrace) + + total_frames = length(stacktrace_rest) + 1 + + first_frame = %Frame{ + level: total_frames, + module: env.module, + function: env.function, + file: env.file, + args: [], + messages: [], + bindings: Map.new(binding), + dbg_frame?: true, + dbg_env: Code.env_for_eval(env), + line: env.line || 1 + } + + frames_rest = + for {{m, f, a, keyword}, index} <- Enum.with_index(stacktrace_rest, 1) do + file = Stacktrace.get_file(m) + line = Keyword.get(keyword, :line, 1) + + %Frame{ + level: total_frames - index, + module: m, + function: {f, a}, + file: file, + args: [], + messages: [], + bindings: %{}, + dbg_frame?: true, + dbg_env: + Code.env_for_eval( + file: file, + line: line + ), + line: line + } + end + + [first_frame | frames_rest] + end + + paused_process = %PausedProcess{stack: stacktrace, ref: ref} + state = put_in(state.paused_processes[pid], paused_process) + + body = %{"reason" => "breakpoint", "threadId" => thread_id, "allThreadsStopped" => false} + Output.send_event("stopped", body) + + {:noreply, %{state | dbg_session: from}} + end + @impl GenServer def handle_cast({:receive_packet, request(_, "disconnect") = packet}, state = %__MODULE__{}) do Output.send_response(packet, %{}) @@ -585,9 +756,9 @@ defmodule ElixirLS.Debugger.Server do state = %__MODULE__{} ) do timeout = Map.get(state.config, "debugExpressionTimeoutMs", 10_000) - bindings = all_variables(state.paused_processes, args["frameId"]) + {binding, env_for_eval} = binding_and_env(state.paused_processes, args["frameId"]) - result = evaluate_code_expression(expr, bindings, timeout) + result = evaluate_code_expression(expr, binding, env_for_eval, timeout) case result do {:ok, value} -> @@ -625,45 +796,75 @@ defmodule ElixirLS.Debugger.Server do defp handle_request(continue_req(_, thread_id) = args, state = %__MODULE__{}) do pid = get_pid_by_thread_id!(state, thread_id) - safe_int_action(pid, :continue) + state = + case state.dbg_session do + {^pid, _ref} = from -> + GenServer.reply(from, {:ok, false}) + %{state | dbg_session: nil} - paused_processes = remove_paused_process(state, pid) - paused_processes = maybe_continue_other_processes(args, paused_processes, pid) + _ -> + safe_int_action(pid, :continue) + state + end - processes_paused? = paused_processes |> Map.keys() |> Enum.any?(&is_pid/1) + state = + state + |> remove_paused_process(pid) + |> maybe_continue_other_processes(args) + + processes_paused? = state.paused_processes |> Map.keys() |> Enum.any?(&is_pid/1) - {%{"allThreadsContinued" => not processes_paused?}, - %{state | paused_processes: paused_processes}} + {%{"allThreadsContinued" => not processes_paused?}, state} end defp handle_request(next_req(_, thread_id) = args, state = %__MODULE__{}) do pid = get_pid_by_thread_id!(state, thread_id) - safe_int_action(pid, :next) - paused_processes = remove_paused_process(state, pid) + state = + if match?({^pid, _ref}, state.dbg_session) do + GenServer.reply(state.dbg_session, {:ok, true}) + %{state | dbg_session: nil} + else + safe_int_action(pid, :next) + state + end + + state = + state + |> remove_paused_process(pid) + |> maybe_continue_other_processes(args) - {%{}, - %{state | paused_processes: maybe_continue_other_processes(args, paused_processes, pid)}} + {%{}, state} end defp handle_request(step_in_req(_, thread_id) = args, state = %__MODULE__{}) do pid = get_pid_by_thread_id!(state, thread_id) + validate_dbg_pid!(state, pid, "stepIn") + safe_int_action(pid, :step) - paused_processes = remove_paused_process(state, pid) - {%{}, - %{state | paused_processes: maybe_continue_other_processes(args, paused_processes, pid)}} + state = + state + |> remove_paused_process(pid) + |> maybe_continue_other_processes(args) + + {%{}, state} end defp handle_request(step_out_req(_, thread_id) = args, state = %__MODULE__{}) do pid = get_pid_by_thread_id!(state, thread_id) + validate_dbg_pid!(state, pid, "stepOut") + safe_int_action(pid, :finish) - paused_processes = remove_paused_process(state, pid) - {%{}, - %{state | paused_processes: maybe_continue_other_processes(args, paused_processes, pid)}} + state = + state + |> remove_paused_process(pid) + |> maybe_continue_other_processes(args) + + {%{}, state} end defp handle_request(completions_req(_, text) = args, state = %__MODULE__{}) do @@ -677,12 +878,16 @@ defmodule ElixirLS.Debugger.Server do |> String.split(["\r\n", "\n", "\r"]) |> Enum.at(line) - # it's not documented but VSCode uses utf16 positions + # It is measured in UTF-16 code units and the client capability + # `columnsStartAt1` determines whether it is 0- or 1-based. column = Utils.dap_character_to_elixir(line, column) prefix = String.slice(line, 0, column) + {binding, _env_for_eval} = + binding_and_env(state.paused_processes, args["arguments"]["frameId"]) + vars = - all_variables(state.paused_processes, args["arguments"]["frameId"]) + binding |> Enum.map(fn {name, value} -> %ElixirSense.Core.State.VarInfo{ name: name, @@ -709,19 +914,31 @@ defmodule ElixirLS.Debugger.Server do } end - defp maybe_continue_other_processes(%{"singleThread" => true}, paused_processes, requested_pid) do - resumed_pids = - for {paused_pid, %PausedProcess{ref: ref}} when paused_pid != requested_pid <- - paused_processes do - safe_int_action(paused_pid, :continue) - true = Process.demonitor(ref, [:flush]) - paused_pid + defp maybe_continue_other_processes(state, %{"singleThread" => true}) do + # continue dbg debug session + state = + case state.dbg_session do + {pid, _ref} = from -> + GenServer.reply(from, {:ok, false}) + {%PausedProcess{ref: ref}, paused_processes} = Map.pop!(state.paused_processes, pid) + true = Process.demonitor(ref, [:flush]) + %{state | dbg_session: nil, paused_processes: paused_processes} + + _ -> + state end - paused_processes |> Map.drop(resumed_pids) + # continue erlang debugger paused processes + for {paused_pid, %PausedProcess{ref: ref}} <- state.paused_processes do + safe_int_action(paused_pid, :continue) + true = Process.demonitor(ref, [:flush]) + paused_pid + end + + %{state | paused_processes: %{}} end - defp maybe_continue_other_processes(_, paused_processes, _requested_pid), do: paused_processes + defp maybe_continue_other_processes(state, _), do: state # TODO consider removing this workaround as the problem seems to no longer affect OTP 24 defp safe_int_action(pid, action) do @@ -763,7 +980,7 @@ defmodule ElixirLS.Debugger.Server do true = Process.demonitor(process.ref, [:flush]) end - paused_processes + %{state | paused_processes: paused_processes} end defp variables(state = %__MODULE__{}, pid, var, start, count, filter) do @@ -810,7 +1027,7 @@ defmodule ElixirLS.Debugger.Server do defp maybe_append_variable_type(map, _, _value), do: map - defp evaluate_code_expression(expr, bindings, timeout) do + defp evaluate_code_expression(expr, binding, env_or_opts, timeout) do task = Task.async(fn -> receive do @@ -818,7 +1035,7 @@ defmodule ElixirLS.Debugger.Server do end try do - {term, _bindings} = Code.eval_string(expr, bindings) + {term, _bindings} = Code.eval_string(expr, binding, env_or_opts) term catch error -> error @@ -837,29 +1054,51 @@ defmodule ElixirLS.Debugger.Server do end end - defp all_variables(paused_processes, nil) do - paused_processes - |> Enum.flat_map(fn - {:evaluator, _} -> - # TODO setVariable? - [] + # for null frameId DAP spec suggest to return variables in the global scope + # there is no global scope in erlang/elixir so instead we flat map all variables + # from all paused processes and evaluator + defp binding_and_env(paused_processes, nil) do + binding = + paused_processes + |> Enum.flat_map(fn + {:evaluator, _} -> + # TODO setVariable? + [] - {_pid, %PausedProcess{} = paused_process} -> - Map.values(paused_process.frame_ids_to_frames) - end) - |> Enum.filter(&match?(%Frame{bindings: bindings} when is_map(bindings), &1)) - |> Enum.flat_map(fn %Frame{bindings: bindings} -> - Binding.to_elixir_variable_names(bindings) - end) + {_pid, %PausedProcess{} = paused_process} -> + Map.values(paused_process.frame_ids_to_frames) + end) + |> Enum.filter(&match?(%Frame{bindings: bindings} when is_map(bindings), &1)) + |> Enum.flat_map(fn %Frame{bindings: bindings} -> + Binding.to_elixir_variable_names(bindings) + end) + + {binding, []} end - defp all_variables(paused_processes, frame_id) do + defp binding_and_env(paused_processes, frame_id) do case find_frame(paused_processes, frame_id) do - {_pid, %Frame{bindings: bindings}} when is_map(bindings) -> - Binding.to_elixir_variable_names(bindings) + {_pid, %Frame{bindings: bindings, dbg_frame?: dbg_frame?} = frame} when is_map(bindings) -> + if dbg_frame? do + {bindings |> Enum.to_list(), frame.dbg_env} + else + {Binding.to_elixir_variable_names(bindings), + [ + file: frame.file, + line: frame.line + ]} + end + + {_pid, %Frame{} = frame} -> + {[], + [ + file: frame.file, + line: frame.line + ]} _ -> - [] + Output.debugger_console("Unable to find frame #{inspect(frame_id)}") + {[], []} end end @@ -1364,12 +1603,11 @@ defmodule ElixirLS.Debugger.Server do defp handle_process_exit(state = %__MODULE__{}, pid) when is_pid(pid) do {thread_id, pids_to_thread_ids} = Map.pop(state.pids_to_thread_ids, pid) - paused_processes = remove_paused_process(state, pid) + state = remove_paused_process(state, pid) state = %__MODULE__{ state | thread_ids_to_pids: Map.delete(state.thread_ids_to_pids, thread_id), - paused_processes: paused_processes, pids_to_thread_ids: pids_to_thread_ids } @@ -1380,7 +1618,12 @@ defmodule ElixirLS.Debugger.Server do }) end - state + if match?({^pid, _ref}, state.dbg_session) do + # no need to respond - the debugged process was waiting in GenServer.call but it exited + %{state | dbg_session: nil} + else + state + end end defp process_name(process_info) do @@ -1408,7 +1651,12 @@ defmodule ElixirLS.Debugger.Server do defp get_stop_reason(state = %__MODULE__{}, :breakpoint_reached, [first_frame = %Frame{} | _]) do file_breakpoints = Map.get(state.breakpoints, first_frame.file, []) - frame_mfa = {first_frame.module, first_frame.function, length(first_frame.args)} + frame_mfa = + case first_frame.function do + {f, a} -> {first_frame.module, f, a} + _ -> nil + end + function_breakpoints = Map.get(state.function_breakpoints, frame_mfa, []) cond do @@ -1463,4 +1711,65 @@ defmodule ElixirLS.Debugger.Server do :error end end + + defp validate_dbg_pid!(state, pid, command) do + if match?({^pid, _ref}, state.dbg_session) do + raise ServerError, + message: "notSupported", + format: "Kernel.dbg breakpoints do not support {command} command", + variables: %{ + "command" => command + } + end + end + + # Made public to be called from dbg/3 to reduce the amount of generated code. + @doc false + def __dbg_pipe_step__(value, string_asts, start_with_pipe?, options) do + asts_string = Enum.intersperse(string_asts, [:faint, " |> ", :reset]) + + asts_string = + if start_with_pipe? do + IO.ANSI.format([:faint, "|> ", :reset, asts_string]) + else + asts_string + end + + [asts_string, :faint, " #=> ", :reset, inspect(value, options), "\n\n"] + |> IO.ANSI.format() + |> IO.write() + + value + end + + defp chunk_pipeline_asts_by_line(asts, %Macro.Env{line: env_line}) do + Enum.chunk_by(asts, fn + {{_fun_or_var, meta, _args}, _pipe_index} -> meta[:line] || env_line + {_other_ast, _pipe_index} -> env_line + end) + end + + defp pipe_chunk_of_asts([{first_ast, _first_index} | asts] = _ast_chunk) do + Enum.reduce(asts, first_ast, fn {ast, index}, acc -> Macro.pipe(acc, ast, index) end) + end + + defp asts_chunk_to_strings(asts) do + Enum.map(asts, fn {ast, _pipe_index} -> Macro.to_string(ast) end) + end + + defp env_with_line_from_asts(asts) do + line = + Enum.find_value(asts, fn + {{_fun_or_var, meta, _args}, _pipe_index} -> meta[:line] + {_ast, _pipe_index} -> nil + end) + + if line do + quote do + %{env | line: unquote(line)} + end + else + quote do: env + end + end end diff --git a/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex b/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex index 1886d1fc0..754b26713 100644 --- a/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex +++ b/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex @@ -6,10 +6,26 @@ defmodule ElixirLS.Debugger.Stacktrace do alias ElixirLS.Debugger.ModuleInfoCache defmodule Frame do - defstruct [:level, :file, :module, :function, :args, :line, :bindings, :messages] + defstruct [ + :level, + :file, + :module, + :function, + :args, + :line, + :bindings, + :messages, + {:dbg_frame?, false}, + :dbg_env + ] + + def name(%__MODULE__{function: function} = frame) when not is_nil(function) do + {f, a} = frame.function + "#{inspect(frame.module)}.#{f}/#{a}" + end def name(%__MODULE__{} = frame) do - "#{inspect(frame.module)}.#{frame.function}/#{Enum.count(frame.args)}" + "#{inspect(frame.module)}" end end @@ -24,10 +40,11 @@ defmodule ElixirLS.Debugger.Stacktrace do first_frame = %Frame{ level: level, module: module, - function: function, + function: {function, Enum.count(args)}, args: args, file: get_file(module), - line: break_line(pid), + # vscode raises invalid request when line is nil + line: break_line(pid) || 1, bindings: get_bindings(meta_pid, level), messages: messages } @@ -45,10 +62,11 @@ defmodule ElixirLS.Debugger.Stacktrace do %Frame{ level: level, module: mod, - function: function, + function: {function, Enum.count(args)}, args: args, file: get_file(mod), - line: line, + # vscode raises invalid request when line is nil + line: line || 1, bindings: Enum.into(bindings, %{}), messages: messages } @@ -86,7 +104,7 @@ defmodule ElixirLS.Debugger.Stacktrace do Enum.into(:int.meta(meta_pid, :bindings, stack_level), %{}) end - defp get_file(module) do + def get_file(module) do Path.expand(to_string(ModuleInfoCache.get(module)[:compile][:source])) end end diff --git a/apps/elixir_ls_debugger/test/debugger_test.exs b/apps/elixir_ls_debugger/test/debugger_test.exs index 530db3eab..f8e53d597 100644 --- a/apps/elixir_ls_debugger/test/debugger_test.exs +++ b/apps/elixir_ls_debugger/test/debugger_test.exs @@ -4,25 +4,33 @@ defmodule ElixirLS.Debugger.ServerTest do # between the debugger's tests and the fixture project's tests. Expect to see output printed # from both. - alias ElixirLS.Debugger.{Server, Protocol, BreakpointCondition, ModuleInfoCache} + alias ElixirLS.Debugger.{Server, Protocol, BreakpointCondition} use ElixirLS.Utils.MixTest.Case, async: false use Protocol - doctest ElixirLS.Debugger.Server + doctest Server setup do {:ok, packet_capture} = ElixirLS.Utils.PacketCapture.start_link(self()) Process.group_leader(Process.whereis(ElixirLS.Debugger.Output), packet_capture) - {:ok, server} = Server.start_link() + {:ok, server} = Server.start_link(name: Server) on_exit(fn -> for mod <- :int.interpreted(), do: :int.nn(mod) :int.auto_attach(false) :int.no_break() :int.clear() - BreakpointCondition.clear() - ModuleInfoCache.clear() + + if Process.alive?(server) do + Process.monitor(server) + Process.exit(server, :normal) + + receive do + {:DOWN, _, _, ^server, _} -> + :ok + end + end end) {:ok, %{server: server}} @@ -2293,6 +2301,541 @@ defmodule ElixirLS.Debugger.ServerTest do end end + if Version.match?(System.version(), ">= 1.14.0") do + describe "Kernel.dbg breakpoints" do + test "breaks on dbg", %{server: server} do + in_fixture(__DIR__, "mix_project", fn -> + abs_path = Path.absname("lib/dbg.ex") + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", _)) + + Server.receive_packet( + server, + launch_req(2, %{ + "request" => "launch", + "type" => "mix_task", + "task" => "run", + "taskArgs" => ["-e", "MixProject.Dbg.simple()"], + "projectDir" => File.cwd!() + }) + ) + + assert_receive(response(_, 2, "launch", _), 3000) + assert_receive(event(_, "initialized", %{}), 5000) + + assert MixProject.Dbg in :int.interpreted() + + Server.receive_packet(server, request(5, "configurationDone", %{})) + assert_receive(response(_, 5, "configurationDone", %{})) + + Server.receive_packet(server, request(6, "threads", %{})) + assert_receive(response(_, 6, "threads", %{"threads" => threads})) + + # ensure thread ids are unique + thread_ids = Enum.map(threads, & &1["id"]) + assert Enum.count(Enum.uniq(thread_ids)) == Enum.count(thread_ids) + + assert_receive event(_, "stopped", %{ + "allThreadsStopped" => false, + "reason" => "breakpoint", + "threadId" => thread_id + }), + 5_000 + + Server.receive_packet(server, stacktrace_req(7, thread_id)) + + assert_receive response(_, 7, "stackTrace", %{ + "totalFrames" => 1, + "stackFrames" => [ + %{ + "column" => 0, + "id" => frame_id, + "line" => 5, + "name" => "MixProject.Dbg.simple/0", + "source" => %{"path" => ^abs_path} + } + ] + }) + when is_integer(frame_id) + + Server.receive_packet(server, scopes_req(8, frame_id)) + + assert_receive response(_, 8, "scopes", %{ + "scopes" => [ + %{ + "expensive" => false, + "indexedVariables" => 0, + "name" => "variables", + "namedVariables" => 1, + "variablesReference" => vars_id + }, + %{ + "expensive" => false, + "indexedVariables" => 0, + "name" => "process info", + "namedVariables" => _, + "variablesReference" => _ + } + ] + }) + + Server.receive_packet(server, vars_req(9, vars_id)) + + assert_receive response(_, 9, "variables", %{ + "variables" => [ + %{ + "name" => "a", + "value" => "5", + "variablesReference" => 0 + } + ] + }), + 1000 + + # stepIn is not supported + Server.receive_packet(server, step_in_req(12, thread_id)) + + assert_receive( + error_response( + _, + 12, + "stepIn", + "notSupported", + "Kernel.dbg breakpoints do not support {command} command", + %{"command" => "stepIn"} + ) + ) + + # stepOut is not supported + Server.receive_packet(server, step_out_req(13, thread_id)) + + assert_receive( + error_response( + _, + 13, + "stepOut", + "notSupported", + "Kernel.dbg breakpoints do not support {command} command", + %{"command" => "stepOut"} + ) + ) + + # next results in continue + Server.receive_packet(server, next_req(14, thread_id)) + assert_receive response(_, 14, "next", %{}) + + assert_receive event(_, "stopped", %{ + "allThreadsStopped" => false, + "reason" => "breakpoint", + "threadId" => ^thread_id + }), + 5_000 + + Server.receive_packet(server, stacktrace_req(141, thread_id)) + + assert_receive response(_, 141, "stackTrace", %{ + "totalFrames" => 1, + "stackFrames" => [ + %{ + "column" => 0, + "id" => frame_id, + "line" => 6, + "name" => "MixProject.Dbg.simple/0", + "source" => %{"path" => ^abs_path} + } + ] + }) + when is_integer(frame_id) + + # continue + Server.receive_packet(server, continue_req(15, thread_id)) + assert_receive response(_, 15, "continue", %{"allThreadsContinued" => true}) + + assert_receive event(_, "stopped", %{ + "allThreadsStopped" => false, + "reason" => "breakpoint", + "threadId" => ^thread_id + }), + 5_000 + + Server.receive_packet(server, stacktrace_req(151, thread_id)) + + assert_receive response(_, 151, "stackTrace", %{ + "totalFrames" => 1, + "stackFrames" => [ + %{ + "column" => 0, + "id" => frame_id, + "line" => 7, + "name" => "MixProject.Dbg.simple/0", + "source" => %{"path" => ^abs_path} + } + ] + }) + when is_integer(frame_id) + + Server.receive_packet(server, continue_req(16, thread_id)) + assert_receive response(_, 16, "continue", %{"allThreadsContinued" => true}) + + refute_receive event(_, "thread", %{ + "reason" => "exited", + "threadId" => ^thread_id + }), + 1_000 + end) + end + + test "stepping through pipe", %{server: server} do + in_fixture(__DIR__, "mix_project", fn -> + abs_path = Path.absname("lib/dbg.ex") + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", _)) + + Server.receive_packet( + server, + launch_req(2, %{ + "request" => "launch", + "type" => "mix_task", + "task" => "run", + "taskArgs" => ["-e", "MixProject.Dbg.pipe()"], + "projectDir" => File.cwd!() + }) + ) + + assert_receive(response(_, 2, "launch", _), 3000) + assert_receive(event(_, "initialized", %{}), 5000) + + assert MixProject.Dbg in :int.interpreted() + + Server.receive_packet(server, request(5, "configurationDone", %{})) + assert_receive(response(_, 5, "configurationDone", %{})) + + Server.receive_packet(server, request(6, "threads", %{})) + assert_receive(response(_, 6, "threads", %{"threads" => threads})) + + # ensure thread ids are unique + thread_ids = Enum.map(threads, & &1["id"]) + assert Enum.count(Enum.uniq(thread_ids)) == Enum.count(thread_ids) + + assert_receive event(_, "stopped", %{ + "allThreadsStopped" => false, + "reason" => "breakpoint", + "threadId" => thread_id + }), + 5_000 + + Server.receive_packet(server, stacktrace_req(7, thread_id)) + + assert_receive response(_, 7, "stackTrace", %{ + "totalFrames" => 1, + "stackFrames" => [ + %{ + "column" => 0, + "id" => frame_id, + "line" => 14, + "name" => "MixProject.Dbg.pipe/0", + "source" => %{"path" => ^abs_path} + } + ] + }) + when is_integer(frame_id) + + Server.receive_packet(server, scopes_req(8, frame_id)) + + assert_receive response(_, 8, "scopes", %{ + "scopes" => [ + %{ + "expensive" => false, + "indexedVariables" => 0, + "name" => "variables", + "namedVariables" => 1, + "variablesReference" => vars_id + }, + %{ + "expensive" => false, + "indexedVariables" => 0, + "name" => "process info", + "namedVariables" => _, + "variablesReference" => _ + } + ] + }) + + Server.receive_packet(server, vars_req(9, vars_id)) + + assert_receive response(_, 9, "variables", %{ + "variables" => [ + %{ + "name" => "a", + "value" => "5", + "variablesReference" => 0 + } + ] + }), + 1000 + + # stepIn is not supported + Server.receive_packet(server, step_in_req(12, thread_id)) + + assert_receive( + error_response( + _, + 12, + "stepIn", + "notSupported", + "Kernel.dbg breakpoints do not support {command} command", + %{"command" => "stepIn"} + ) + ) + + # stepOut is not supported + Server.receive_packet(server, step_out_req(13, thread_id)) + + assert_receive( + error_response( + _, + 13, + "stepOut", + "notSupported", + "Kernel.dbg breakpoints do not support {command} command", + %{"command" => "stepOut"} + ) + ) + + # next steps through pipe + Server.receive_packet(server, next_req(14, thread_id)) + assert_receive response(_, 14, "next", %{}) + + assert_receive event(_, "stopped", %{ + "allThreadsStopped" => false, + "reason" => "breakpoint", + "threadId" => ^thread_id + }), + 5_000 + + Server.receive_packet(server, stacktrace_req(141, thread_id)) + + assert_receive response(_, 141, "stackTrace", %{ + "totalFrames" => 1, + "stackFrames" => [ + %{ + "column" => 0, + "id" => frame_id, + "line" => 15, + "name" => "MixProject.Dbg.pipe/0", + "source" => %{"path" => ^abs_path} + } + ] + }) + when is_integer(frame_id) + + # continue skips pipe steps + Server.receive_packet(server, continue_req(15, thread_id)) + assert_receive response(_, 15, "continue", %{"allThreadsContinued" => true}) + + refute_receive event(_, "stopped", %{ + "allThreadsStopped" => false, + "reason" => "breakpoint", + "threadId" => ^thread_id + }), + 1_000 + + refute_receive event(_, "thread", %{ + "reason" => "exited", + "threadId" => ^thread_id + }) + end) + end + + test "breaks on dbg when module is not interpreted", %{server: server} do + in_fixture(__DIR__, "mix_project", fn -> + abs_path = Path.absname("lib/dbg.ex") + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", _)) + + Server.receive_packet( + server, + launch_req(2, %{ + "request" => "launch", + "type" => "mix_task", + "task" => "run", + "taskArgs" => ["-e", "MixProject.Dbg.simple()"], + "projectDir" => File.cwd!(), + # disable auto interpret + "debugAutoInterpretAllModules" => false + }) + ) + + assert_receive(response(_, 2, "launch", _), 3000) + assert_receive(event(_, "initialized", %{}), 5000) + + refute MixProject.Dbg in :int.interpreted() + + Server.receive_packet(server, request(5, "configurationDone", %{})) + assert_receive(response(_, 5, "configurationDone", %{})) + + Server.receive_packet(server, request(6, "threads", %{})) + assert_receive(response(_, 6, "threads", %{"threads" => threads})) + + # ensure thread ids are unique + thread_ids = Enum.map(threads, & &1["id"]) + assert Enum.count(Enum.uniq(thread_ids)) == Enum.count(thread_ids) + + assert_receive event(_, "stopped", %{ + "allThreadsStopped" => false, + "reason" => "breakpoint", + "threadId" => thread_id + }), + 5_000 + + Server.receive_packet(server, stacktrace_req(7, thread_id)) + + assert_receive response(_, 7, "stackTrace", %{ + "totalFrames" => 7, + "stackFrames" => [ + %{ + "column" => 0, + "id" => frame_id, + "line" => 5, + "name" => "MixProject.Dbg.simple/0", + "source" => %{"path" => ^abs_path} + } + | _ + ] + }) + when is_integer(frame_id) + + Server.receive_packet(server, scopes_req(8, frame_id)) + + assert_receive response(_, 8, "scopes", %{ + "scopes" => [ + %{ + "expensive" => false, + "indexedVariables" => 0, + "name" => "variables", + "namedVariables" => 1, + "variablesReference" => vars_id + }, + %{ + "expensive" => false, + "indexedVariables" => 0, + "name" => "process info", + "namedVariables" => _, + "variablesReference" => _ + } + ] + }) + + Server.receive_packet(server, vars_req(9, vars_id)) + + assert_receive response(_, 9, "variables", %{ + "variables" => [ + %{ + "name" => "a", + "value" => "5", + "variablesReference" => 0 + } + ] + }), + 1000 + + # stepIn is not supported + Server.receive_packet(server, step_in_req(12, thread_id)) + + assert_receive( + error_response( + _, + 12, + "stepIn", + "notSupported", + "Kernel.dbg breakpoints do not support {command} command", + %{"command" => "stepIn"} + ) + ) + + # stepOut is not supported + Server.receive_packet(server, step_out_req(13, thread_id)) + + assert_receive( + error_response( + _, + 13, + "stepOut", + "notSupported", + "Kernel.dbg breakpoints do not support {command} command", + %{"command" => "stepOut"} + ) + ) + + # next results in continue + Server.receive_packet(server, next_req(14, thread_id)) + assert_receive response(_, 14, "next", %{}) + + assert_receive event(_, "stopped", %{ + "allThreadsStopped" => false, + "reason" => "breakpoint", + "threadId" => ^thread_id + }), + 5_000 + + Server.receive_packet(server, stacktrace_req(141, thread_id)) + + assert_receive response(_, 141, "stackTrace", %{ + "totalFrames" => 7, + "stackFrames" => [ + %{ + "column" => 0, + "id" => frame_id, + "line" => 6, + "name" => "MixProject.Dbg.simple/0", + "source" => %{"path" => ^abs_path} + } + | _ + ] + }) + when is_integer(frame_id) + + # continue + Server.receive_packet(server, continue_req(15, thread_id)) + assert_receive response(_, 15, "continue", %{"allThreadsContinued" => true}) + + assert_receive event(_, "stopped", %{ + "allThreadsStopped" => false, + "reason" => "breakpoint", + "threadId" => ^thread_id + }), + 5_000 + + Server.receive_packet(server, stacktrace_req(151, thread_id)) + + assert_receive response(_, 151, "stackTrace", %{ + "totalFrames" => 7, + "stackFrames" => [ + %{ + "column" => 0, + "id" => frame_id, + "line" => 7, + "name" => "MixProject.Dbg.simple/0", + "source" => %{"path" => ^abs_path} + } + | _ + ] + }) + when is_integer(frame_id) + + Server.receive_packet(server, continue_req(16, thread_id)) + assert_receive response(_, 16, "continue", %{"allThreadsContinued" => true}) + + refute_receive event(_, "thread", %{ + "reason" => "exited", + "threadId" => ^thread_id + }), + 1_000 + end) + end + end + end + @tag :fixture test "server tracks running processes", %{server: server} do in_fixture(__DIR__, "mix_project", fn -> diff --git a/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/dbg.ex b/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/dbg.ex new file mode 100644 index 000000000..5d32f6635 --- /dev/null +++ b/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/dbg.ex @@ -0,0 +1,21 @@ +if Version.match?(System.version(), ">= 1.14.0") do + defmodule MixProject.Dbg do + def simple() do + a = 5 + b = __ENV__.file |> dbg() + c = String.split(b, "/", trim: true) |> dbg() + d = List.last(c) |> dbg() + File.exists?(d) + end + + def pipe() do + a = 5 + + __ENV__.file + |> String.split("/", trim: true) + |> List.last() + |> File.exists?() + |> dbg() + end + end +end