From 4ae6e22a9c01671192f827b1d0dcf5c573852061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Samson?= Date: Thu, 14 Jan 2021 19:21:07 +0100 Subject: [PATCH] Improve debugger stability (#457) * remove legacy io_request handlers we don't support OTP < R15B * rescue MatchError in :int calls Fixes #455 * make output device better conform to erlang I/O protocol see https://erlang.org/doc/apps/stdlib/io_protocol.html for details * return WireProtocol.send error to the caller no need to IO.warn if write fails * we are redirection stderr to stdout, use stdout as underlying device * inspect error * monitor debugged processes add test for mix task exit Fixes #454 * avoid debugger crashes when handling requests for no longer existing thread, frame and variable ids Fixes #452 * add test * forbid changes of underlying device opts * refactor and add tests coverage to invalid requests * Map.pop! is available since elixir 1.10 * run formatter --- apps/elixir_ls_debugger/lib/debugger/cli.ex | 1 + .../elixir_ls_debugger/lib/debugger/output.ex | 39 +- .../elixir_ls_debugger/lib/debugger/server.ex | 364 ++++++++++++------ .../lib/debugger/stacktrace.ex | 4 +- .../elixir_ls_debugger/test/debugger_test.exs | 259 ++++++++++++- .../fixtures/mix_project/lib/mix_project.ex | 22 ++ apps/elixir_ls_utils/lib/output_device.ex | 134 +++++-- apps/elixir_ls_utils/lib/wire_protocol.ex | 24 +- .../test/output_device_test.exs | 329 ++++++++++++++++ .../lib/language_server/json_rpc.ex | 4 +- 10 files changed, 992 insertions(+), 188 deletions(-) create mode 100644 apps/elixir_ls_utils/test/output_device_test.exs diff --git a/apps/elixir_ls_debugger/lib/debugger/cli.ex b/apps/elixir_ls_debugger/lib/debugger/cli.ex index fce0527b0..201b6d672 100644 --- a/apps/elixir_ls_debugger/lib/debugger/cli.ex +++ b/apps/elixir_ls_debugger/lib/debugger/cli.ex @@ -6,6 +6,7 @@ defmodule ElixirLS.Debugger.CLI do WireProtocol.intercept_output(&Output.print/1, &Output.print_err/1) Launch.start_mix() {:ok, _} = Application.ensure_all_started(:elixir_ls_debugger, :permanent) + IO.puts("Started ElixirLS debugger v#{Launch.debugger_version()}") Launch.print_versions() Launch.limit_num_schedulers() diff --git a/apps/elixir_ls_debugger/lib/debugger/output.ex b/apps/elixir_ls_debugger/lib/debugger/output.ex index 2731f81d8..bd1481c37 100644 --- a/apps/elixir_ls_debugger/lib/debugger/output.ex +++ b/apps/elixir_ls_debugger/lib/debugger/output.ex @@ -7,7 +7,7 @@ defmodule ElixirLS.Debugger.Output do are sent with sequence numbers that are unique and sequential, and includes client functions for sending these messages. """ - import ElixirLS.Utils.WireProtocol, only: [send: 1] + alias ElixirLS.Utils.WireProtocol use GenServer use ElixirLS.Debugger.Protocol @@ -29,12 +29,12 @@ defmodule ElixirLS.Debugger.Output do GenServer.call(server, {:send_event, event, body}) end - def print(server \\ __MODULE__, str) do - send_event(server, "output", %{"category" => "stdout", "output" => to_string(str)}) + def print(server \\ __MODULE__, str) when is_binary(str) do + send_event(server, "output", %{"category" => "stdout", "output" => str}) end - def print_err(server \\ __MODULE__, str) do - send_event(server, "output", %{"category" => "stderr", "output" => to_string(str)}) + def print_err(server \\ __MODULE__, str) when is_binary(str) do + send_event(server, "output", %{"category" => "stderr", "output" => str}) end ## Server callbacks @@ -46,27 +46,28 @@ defmodule ElixirLS.Debugger.Output do @impl GenServer def handle_call({:send_response, request_packet, body}, _from, seq) do - send(response(seq, request_packet["seq"], request_packet["command"], body)) - {:reply, :ok, seq + 1} + res = WireProtocol.send(response(seq, request_packet["seq"], request_packet["command"], body)) + {:reply, res, seq + 1} end def handle_call({:send_error_response, request_packet, message, format, variables}, _from, seq) do - send( - error_response( - seq, - request_packet["seq"], - request_packet["command"], - message, - format, - variables + res = + WireProtocol.send( + error_response( + seq, + request_packet["seq"], + request_packet["command"], + message, + format, + variables + ) ) - ) - {:reply, :ok, seq + 1} + {:reply, res, seq + 1} end def handle_call({:send_event, event, body}, _from, seq) do - send(event(seq, event, body)) - {:reply, :ok, seq + 1} + res = WireProtocol.send(event(seq, event, body)) + {:reply, res, seq + 1} end end diff --git a/apps/elixir_ls_debugger/lib/debugger/server.ex b/apps/elixir_ls_debugger/lib/debugger/server.ex index d55828696..00165615a 100644 --- a/apps/elixir_ls_debugger/lib/debugger/server.ex +++ b/apps/elixir_ls_debugger/lib/debugger/server.ex @@ -36,7 +36,8 @@ defmodule ElixirLS.Debugger.Server do frames: %{}, frames_inverse: %{}, vars: %{}, - vars_inverse: %{} + vars_inverse: %{}, + ref: nil end ## Client API @@ -64,12 +65,12 @@ defmodule ElixirLS.Debugger.Server do end @impl GenServer - def handle_cast({:receive_packet, request(_, "disconnect") = packet}, state) do + def handle_cast({:receive_packet, request(_, "disconnect") = packet}, state = %__MODULE__{}) do Output.send_response(packet, %{}) {:noreply, state, {:continue, :disconnect}} end - def handle_cast({:receive_packet, request(_, _) = packet}, state) do + def handle_cast({:receive_packet, request(_, _) = packet}, state = %__MODULE__{}) do try do if state.client_info == nil do case packet do @@ -99,21 +100,31 @@ defmodule ElixirLS.Debugger.Server do end @impl GenServer - def handle_cast({:breakpoint_reached, pid}, state) do - {state, thread_id} = ensure_thread_id(state, pid) - - paused_process = %PausedProcess{stack: Stacktrace.get(pid)} - state = put_in(state.paused_processes[pid], paused_process) - - body = %{"reason" => "breakpoint", "threadId" => thread_id, "allThreadsStopped" => false} - Output.send_event("stopped", body) + def handle_cast({:breakpoint_reached, pid}, state = %__MODULE__{}) do + # when debugged pid exits we get another breakpoint reached message (at least on OTP 23) + # check if process is alive to not debug dead ones + state = + if Process.alive?(pid) do + # monitor to clanup state if process dies + ref = Process.monitor(pid) + {state, thread_id} = ensure_thread_id(state, pid) + + paused_process = %PausedProcess{stack: Stacktrace.get(pid), ref: ref} + state = put_in(state.paused_processes[pid], paused_process) + + body = %{"reason" => "breakpoint", "threadId" => thread_id, "allThreadsStopped" => false} + Output.send_event("stopped", body) + state + else + state + end {:noreply, state} end # the `:DOWN` message is not delivered under normal conditions as the process calls `Process.sleep(:infinity)` @impl GenServer - def handle_info({:DOWN, ref, :process, _pid, reason}, %{task_ref: ref} = state) do + def handle_info({:DOWN, ref, :process, _pid, reason}, %__MODULE__{task_ref: ref} = state) do exit_code = case reason do :normal -> @@ -134,10 +145,33 @@ defmodule ElixirLS.Debugger.Server do {:noreply, %{state | task_ref: nil}} end + def handle_info({:DOWN, _ref, :process, pid, reason}, state = %__MODULE__{}) do + IO.puts( + :standard_error, + "debugged process #{inspect(pid)} exited with reason #{Exception.format_exit(reason)}" + ) + + thread_id = state.threads_inverse[pid] + state = remove_paused_process(state, pid) + + state = %{ + state + | threads: state.threads |> Map.delete(thread_id), + threads_inverse: state.threads_inverse |> Map.delete(pid) + } + + Output.send_event("thread", %{ + "reason" => "exited", + "threadId" => thread_id + }) + + {:noreply, state} + end + # If we get the disconnect request from the client, we continue with :disconnect so the server will # die right after responding to the request @impl GenServer - def handle_continue(:disconnect, state) do + def handle_continue(:disconnect, state = %__MODULE__{}) do unless Application.get_env(:elixir_ls_debugger, :test_mode) do System.halt(0) else @@ -148,7 +182,7 @@ defmodule ElixirLS.Debugger.Server do end @impl GenServer - def terminate(reason, _state) do + def terminate(reason, _state = %__MODULE__{}) do if reason != :normal do IO.puts(:standard_error, "(Debugger) Terminating because #{Exception.format_exit(reason)}") end @@ -156,11 +190,11 @@ defmodule ElixirLS.Debugger.Server do ## Helpers - defp handle_request(initialize_req(_, client_info), %{client_info: nil} = state) do + defp handle_request(initialize_req(_, client_info), %__MODULE__{client_info: nil} = state) do {capabilities(), %{state | client_info: client_info}} end - defp handle_request(initialize_req(_, _client_info), _state) do + defp handle_request(initialize_req(_, _client_info), _state = %__MODULE__{}) do raise ServerError, message: "invalidRequest", format: "Debugger request {command} was not expected", @@ -169,7 +203,7 @@ defmodule ElixirLS.Debugger.Server do } end - defp handle_request(launch_req(_, config), state) do + defp handle_request(launch_req(_, config), state = %__MODULE__{}) do {_, ref} = spawn_monitor(fn -> initialize(config) end) receive do @@ -188,7 +222,10 @@ defmodule ElixirLS.Debugger.Server do {%{}, %{state | config: config}} end - defp handle_request(set_breakpoints_req(_, %{"path" => path}, breakpoints), state) do + defp handle_request( + set_breakpoints_req(_, %{"path" => path}, breakpoints), + state = %__MODULE__{} + ) do new_lines = for %{"line" => line} <- breakpoints, do: line existing_bps = state.breakpoints[path] || [] existing_bp_lines = for {_module, line} <- existing_bps, do: line @@ -212,11 +249,11 @@ defmodule ElixirLS.Debugger.Server do {%{"breakpoints" => breakpoints_json}, state} end - defp handle_request(set_exception_breakpoints_req(_), state) do + defp handle_request(set_exception_breakpoints_req(_), state = %__MODULE__{}) do {%{}, state} end - defp handle_request(configuration_done_req(_), state) do + defp handle_request(configuration_done_req(_), state = %__MODULE__{}) do server = :erlang.process_info(self())[:registered_name] || self() :int.auto_attach([:break], {__MODULE__, :breakpoint_reached, [server]}) @@ -227,7 +264,7 @@ defmodule ElixirLS.Debugger.Server do {%{}, %{state | task_ref: task_ref}} end - defp handle_request(threads_req(_), state) do + defp handle_request(threads_req(_), state = %__MODULE__{}) do pids = :erlang.processes() {state, thread_ids} = ensure_thread_ids(state, pids) @@ -252,75 +289,117 @@ defmodule ElixirLS.Debugger.Server do {%{"threads" => threads}, state} end - defp handle_request(request(_, "stackTrace", %{"threadId" => thread_id} = args), state) do - pid = state.threads[thread_id] - paused_process = state.paused_processes[pid] - - total_frames = Enum.count(paused_process.stack) + defp handle_request( + request(_, "stackTrace", %{"threadId" => thread_id} = args), + state = %__MODULE__{} + ) do + pid = get_pid_by_thread_id!(state, thread_id) - start_frame = - case args do - %{"startFrame" => start_frame} when is_integer(start_frame) -> start_frame - _ -> 0 - end + case state.paused_processes[pid] do + %PausedProcess{} = paused_process -> + total_frames = Enum.count(paused_process.stack) - end_frame = - case args do - %{"levels" => levels} when is_integer(levels) and levels > 0 -> start_frame + levels - _ -> -1 - end - - stack_frames = Enum.slice(paused_process.stack, start_frame..end_frame) - {state, frame_ids} = ensure_frame_ids(state, pid, stack_frames) - - stack_frames_json = - for {stack_frame, frame_id} <- List.zip([stack_frames, frame_ids]) do - %{ - "id" => frame_id, - "name" => Stacktrace.Frame.name(stack_frame), - "line" => stack_frame.line, - "column" => 0, - "source" => %{"path" => stack_frame.file} - } - end + start_frame = + case args do + %{"startFrame" => start_frame} when is_integer(start_frame) -> start_frame + _ -> 0 + end - {%{"stackFrames" => stack_frames_json, "totalFrames" => total_frames}, state} - end + end_frame = + case args do + %{"levels" => levels} when is_integer(levels) and levels > 0 -> start_frame + levels + _ -> -1 + end - defp handle_request(request(_, "scopes", %{"frameId" => frame_id}), state) do - {pid, frame} = find_frame(state.paused_processes, frame_id) + stack_frames = Enum.slice(paused_process.stack, start_frame..end_frame) + {state, frame_ids} = ensure_frame_ids(state, pid, stack_frames) + + stack_frames_json = + for {%Frame{} = stack_frame, frame_id} <- List.zip([stack_frames, frame_ids]) do + %{ + "id" => frame_id, + "name" => Stacktrace.Frame.name(stack_frame), + "line" => stack_frame.line, + "column" => 0, + "source" => %{"path" => stack_frame.file} + } + end - {state, args_id} = ensure_var_id(state, pid, frame.args) - {state, bindings_id} = ensure_var_id(state, pid, frame.bindings) + {%{"stackFrames" => stack_frames_json, "totalFrames" => total_frames}, state} - vars_scope = %{ - "name" => "variables", - "variablesReference" => bindings_id, - "namedVariables" => Enum.count(frame.bindings), - "indexedVariables" => 0, - "expensive" => false - } + nil -> + raise ServerError, + message: "invalidArgument", + format: "process not paused: {threadId}", + variables: %{ + "threadId" => inspect(thread_id) + } + end + end - args_scope = %{ - "name" => "arguments", - "variablesReference" => args_id, - "namedVariables" => 0, - "indexedVariables" => Enum.count(frame.args), - "expensive" => false - } + defp handle_request(request(_, "scopes", %{"frameId" => frame_id}), state = %__MODULE__{}) do + {state, scopes} = + case find_frame(state.paused_processes, frame_id) do + {pid, %Frame{} = frame} -> + {state, args_id} = ensure_var_id(state, pid, frame.args) + {state, bindings_id} = ensure_var_id(state, pid, frame.bindings) + + vars_scope = %{ + "name" => "variables", + "variablesReference" => bindings_id, + "namedVariables" => Enum.count(frame.bindings), + "indexedVariables" => 0, + "expensive" => false + } + + args_scope = %{ + "name" => "arguments", + "variablesReference" => args_id, + "namedVariables" => 0, + "indexedVariables" => Enum.count(frame.args), + "expensive" => false + } + + scopes = if Enum.count(frame.args) > 0, do: [vars_scope, args_scope], else: [vars_scope] + {state, scopes} + + nil -> + raise ServerError, + message: "invalidArgument", + format: "frameId not found: {frameId}", + variables: %{ + "frameId" => inspect(frame_id) + } + end - scopes = if Enum.count(frame.args) > 0, do: [vars_scope, args_scope], else: [vars_scope] {%{"scopes" => scopes}, state} end - defp handle_request(request(_, "variables", %{"variablesReference" => var_id} = args), state) do - {pid, var} = find_var(state.paused_processes, var_id) - {state, vars_json} = variables(state, pid, var, args["start"], args["count"], args["filter"]) + defp handle_request( + request(_, "variables", %{"variablesReference" => var_id} = args), + state = %__MODULE__{} + ) do + {state, vars_json} = + case find_var(state.paused_processes, var_id) do + {pid, var} -> + variables(state, pid, var, args["start"], args["count"], args["filter"]) + + nil -> + raise ServerError, + message: "invalidArgument", + format: "variablesReference not found: {variablesReference}", + variables: %{ + "variablesReference" => inspect(var_id) + } + end {%{"variables" => vars_json}, state} end - defp handle_request(request(_cmd, "evaluate", %{"expression" => expr} = _args), state) do + defp handle_request( + request(_cmd, "evaluate", %{"expression" => expr} = _args), + state = %__MODULE__{} + ) do timeout = 1_000 bindings = all_variables(state.paused_processes) @@ -329,39 +408,79 @@ defmodule ElixirLS.Debugger.Server do {%{"result" => inspect(result), "variablesReference" => 0}, state} end - defp handle_request(continue_req(_, thread_id), state) do - pid = state.threads[thread_id] - state = remove_paused_process(state, pid) + defp handle_request(continue_req(_, thread_id), state = %__MODULE__{}) do + pid = get_pid_by_thread_id!(state, thread_id) - :ok = :int.continue(pid) - {%{"allThreadsContinued" => false}, state} + try do + :int.continue(pid) + state = remove_paused_process(state, pid) + {%{"allThreadsContinued" => false}, state} + rescue + e in MatchError -> + raise ServerError, + message: "serverError", + format: ":int.continue failed: {message}", + variables: %{ + "message" => inspect(Exception.message(e)) + } + end end - defp handle_request(next_req(_, thread_id), state) do - pid = state.threads[thread_id] - state = remove_paused_process(state, pid) + defp handle_request(next_req(_, thread_id), state = %__MODULE__{}) do + pid = get_pid_by_thread_id!(state, thread_id) - :int.next(pid) - {%{}, state} + try do + :int.next(pid) + state = remove_paused_process(state, pid) + {%{}, state} + rescue + e in MatchError -> + raise ServerError, + message: "serverError", + format: ":int.next failed: {message}", + variables: %{ + "message" => inspect(Exception.message(e)) + } + end end - defp handle_request(step_in_req(_, thread_id), state) do - pid = state.threads[thread_id] - state = remove_paused_process(state, pid) + defp handle_request(step_in_req(_, thread_id), state = %__MODULE__{}) do + pid = get_pid_by_thread_id!(state, thread_id) - :int.step(pid) - {%{}, state} + try do + :int.step(pid) + state = remove_paused_process(state, pid) + {%{}, state} + rescue + e in MatchError -> + raise ServerError, + message: "serverError", + format: ":int.stop failed: {message}", + variables: %{ + "message" => inspect(Exception.message(e)) + } + end end - defp handle_request(step_out_req(_, thread_id), state) do - pid = state.threads[thread_id] - state = remove_paused_process(state, pid) + defp handle_request(step_out_req(_, thread_id), state = %__MODULE__{}) do + pid = get_pid_by_thread_id!(state, thread_id) - :int.finish(pid) - {%{}, state} + try do + :int.finish(pid) + state = remove_paused_process(state, pid) + {%{}, state} + rescue + e in MatchError -> + raise ServerError, + message: "serverError", + format: ":int.finish failed: {message}", + variables: %{ + "message" => inspect(Exception.message(e)) + } + end end - defp handle_request(request(_, command), _state) when is_binary(command) do + defp handle_request(request(_, command), _state = %__MODULE__{}) when is_binary(command) do raise ServerError, message: "notSupported", format: "Debugger request {command} is currently not supported", @@ -370,13 +489,28 @@ defmodule ElixirLS.Debugger.Server do } end - defp remove_paused_process(state, pid) do - update_in(state.paused_processes, fn paused_processes -> - Map.delete(paused_processes, pid) - end) + defp get_pid_by_thread_id!(state = %__MODULE__{}, thread_id) do + case state.threads[thread_id] do + nil -> + raise ServerError, + message: "invalidArgument", + format: "threadId not found: {threadId}", + variables: %{ + "threadId" => inspect(thread_id) + } + + pid -> + pid + end end - defp variables(state, pid, var, start, count, filter) do + defp remove_paused_process(state = %__MODULE__{}, pid) do + {process = %PausedProcess{}, paused_processes} = Map.pop(state.paused_processes, pid) + true = Process.demonitor(process.ref, [:flush]) + %__MODULE__{state | paused_processes: paused_processes} + end + + defp variables(state = %__MODULE__{}, pid, var, start, count, filter) do children = if (filter == "named" and Variables.child_type(var) == :indexed) or (filter == "indexed" and Variables.child_type(var) == :named) do @@ -385,7 +519,7 @@ defmodule ElixirLS.Debugger.Server do Variables.children(var, start, count) end - Enum.reduce(children, {state, []}, fn {name, value}, {state, result} -> + Enum.reduce(children, {state, []}, fn {name, value}, {state = %__MODULE__{}, result} -> {state, var_id} = if Variables.expandable?(value) do ensure_var_id(state, pid, value) @@ -440,7 +574,9 @@ defmodule ElixirLS.Debugger.Server do defp all_variables(paused_processes) do paused_processes - |> Enum.flat_map(fn {_pid, paused_process} -> paused_process.frames |> Map.values() end) + |> Enum.flat_map(fn {_pid, %PausedProcess{} = paused_process} -> + paused_process.frames |> Map.values() + end) |> Enum.filter(&match?(%Frame{bindings: bindings} when is_map(bindings), &1)) |> Enum.flat_map(fn %Frame{bindings: bindings} -> bindings |> Enum.map(&rename_binding_to_classic_variable/1) @@ -460,7 +596,7 @@ defmodule ElixirLS.Debugger.Server do end defp find_var(paused_processes, var_id) do - Enum.find_value(paused_processes, fn {pid, paused_process} -> + Enum.find_value(paused_processes, fn {pid, %PausedProcess{} = paused_process} -> if Map.has_key?(paused_process.vars, var_id) do {pid, paused_process.vars[var_id]} end @@ -468,14 +604,14 @@ defmodule ElixirLS.Debugger.Server do end defp find_frame(paused_processes, frame_id) do - Enum.find_value(paused_processes, fn {pid, paused_process} -> + Enum.find_value(paused_processes, fn {pid, %PausedProcess{} = paused_process} -> if Map.has_key?(paused_process.frames, frame_id) do {pid, paused_process.frames[frame_id]} end end) end - defp ensure_thread_id(state, pid) do + defp ensure_thread_id(state = %__MODULE__{}, pid) do if Map.has_key?(state.threads_inverse, pid) do {state, state.threads_inverse[pid]} else @@ -487,14 +623,18 @@ defmodule ElixirLS.Debugger.Server do end end - defp ensure_thread_ids(state, pids) do + defp ensure_thread_ids(state = %__MODULE__{}, pids) do Enum.reduce(pids, {state, []}, fn pid, {state, ids} -> {state, id} = ensure_thread_id(state, pid) {state, ids ++ [id]} end) end - defp ensure_var_id(state, pid, var) do + defp ensure_var_id(state = %__MODULE__{}, pid, var) do + unless Map.has_key?(state.paused_processes, pid) do + raise ArgumentError, message: "paused process #{inspect(pid)} not found" + end + if Map.has_key?(state.paused_processes[pid].vars_inverse, var) do {state, state.paused_processes[pid].vars_inverse[var]} else @@ -506,14 +646,18 @@ defmodule ElixirLS.Debugger.Server do end end - defp ensure_frame_ids(state, pid, stack_frames) do + defp ensure_frame_ids(state = %__MODULE__{}, pid, stack_frames) do Enum.reduce(stack_frames, {state, []}, fn stack_frame, {state, ids} -> {state, id} = ensure_frame_id(state, pid, stack_frame) {state, ids ++ [id]} end) end - defp ensure_frame_id(state, pid, frame) do + defp ensure_frame_id(state = %__MODULE__{}, pid, %Frame{} = frame) do + unless Map.has_key?(state.paused_processes, pid) do + raise ArgumentError, message: "paused process #{inspect(pid)} not found" + end + if Map.has_key?(state.paused_processes[pid].frames_inverse, frame) do {state, state.paused_processes[pid].frames_inverse[frame]} else diff --git a/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex b/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex index e27aac013..a5a887364 100644 --- a/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex +++ b/apps/elixir_ls_debugger/lib/debugger/stacktrace.ex @@ -6,7 +6,7 @@ defmodule ElixirLS.Debugger.Stacktrace do defmodule Frame do defstruct [:level, :file, :module, :function, :args, :line, :bindings] - def name(frame) do + def name(%__MODULE__{} = frame) do "#{inspect(frame.module)}.#{frame.function}/#{Enum.count(frame.args)}" end end @@ -52,7 +52,7 @@ defmodule ElixirLS.Debugger.Stacktrace do [first_frame | other_frames] error -> - IO.warn("Failed to obtain meta pid for #{inspect(pid)}: #{error}") + IO.warn("Failed to obtain meta pid for #{inspect(pid)}: #{inspect(error)}") [] end end diff --git a/apps/elixir_ls_debugger/test/debugger_test.exs b/apps/elixir_ls_debugger/test/debugger_test.exs index f05b2a708..fd0d2d5bd 100644 --- a/apps/elixir_ls_debugger/test/debugger_test.exs +++ b/apps/elixir_ls_debugger/test/debugger_test.exs @@ -188,17 +188,274 @@ defmodule ElixirLS.Debugger.ServerTest do Server.receive_packet(server, continue_req(10, thread_id)) assert_receive response(_, 10, "continue", %{"allThreadsContinued" => false}) + end) + end + + test "handles invalid requests", %{server: server} do + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true})) + + Server.receive_packet( + server, + launch_req(2, %{ + "request" => "launch", + "type" => "mix_task", + "task" => "test", + "projectDir" => File.cwd!() + }) + ) + + assert_receive(response(_, 2, "launch", %{}), 5000) + assert_receive(event(_, "initialized", %{})) + + Server.receive_packet( + server, + set_breakpoints_req(3, %{"path" => "lib/mix_project.ex"}, [%{"line" => 3}]) + ) + + assert_receive( + response(_, 3, "setBreakpoints", %{"breakpoints" => [%{"verified" => true}]}) + ) + + Server.receive_packet(server, request(4, "setExceptionBreakpoints", %{"filters" => []})) + assert_receive(response(_, 4, "setExceptionBreakpoints", %{})) + + 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 + }) - Server.receive_packet(server, request(11, "someRequest", %{"threadId" => 123})) + Server.receive_packet(server, stacktrace_req(7, "not existing")) + + assert_receive error_response( + _, + 7, + "stackTrace", + "invalidArgument", + "threadId not found: {threadId}", + %{"threadId" => "\"not existing\""} + ) + + Server.receive_packet(server, scopes_req(8, "not existing")) + + assert_receive error_response( + _, + 8, + "scopes", + "invalidArgument", + "frameId not found: {frameId}", + %{"frameId" => "\"not existing\""} + ) + + Server.receive_packet(server, vars_req(9, "not existing")) + + assert_receive error_response( + _, + 9, + "variables", + "invalidArgument", + "variablesReference not found: {variablesReference}", + %{"variablesReference" => "\"not existing\""} + ) + + Server.receive_packet(server, next_req(10, "not existing")) + + assert_receive error_response( + _, + 10, + "next", + "invalidArgument", + "threadId not found: {threadId}", + %{"threadId" => "\"not existing\""} + ) + + Server.receive_packet(server, step_in_req(11, "not existing")) assert_receive error_response( _, 11, + "stepIn", + "invalidArgument", + "threadId not found: {threadId}", + %{"threadId" => "\"not existing\""} + ) + + Server.receive_packet(server, step_out_req(12, "not existing")) + + assert_receive error_response( + _, + 12, + "stepOut", + "invalidArgument", + "threadId not found: {threadId}", + %{"threadId" => "\"not existing\""} + ) + + Server.receive_packet(server, continue_req(13, "not existing")) + + assert_receive error_response( + _, + 13, + "continue", + "invalidArgument", + "threadId not found: {threadId}", + %{"threadId" => "\"not existing\""} + ) + + Server.receive_packet(server, request(14, "someRequest", %{"threadId" => 123})) + + assert_receive error_response( + _, + 14, "someRequest", "notSupported", "Debugger request {command} is currently not supported", %{"command" => "someRequest"} ) + + Server.receive_packet(server, continue_req(15, thread_id)) + assert_receive response(_, 15, "continue", %{"allThreadsContinued" => false}) + + Server.receive_packet(server, stacktrace_req(7, thread_id)) + thread_id_str = inspect(thread_id) + + assert_receive error_response( + _, + 7, + "stackTrace", + "invalidArgument", + "process not paused: {threadId}", + %{"threadId" => ^thread_id_str} + ) + end) + end + + test "notifies about process exit", %{server: server} do + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true})) + + Server.receive_packet( + server, + launch_req(2, %{ + "request" => "launch", + "type" => "mix_task", + "task" => "run", + "taskArgs" => ["-e", "MixProject.exit()"], + "projectDir" => File.cwd!() + }) + ) + + assert_receive(response(_, 2, "launch", %{}), 5000) + assert_receive(event(_, "initialized", %{})) + + Server.receive_packet( + server, + set_breakpoints_req(3, %{"path" => "lib/mix_project.ex"}, [%{"line" => 17}]) + ) + + assert_receive( + response(_, 3, "setBreakpoints", %{"breakpoints" => [%{"verified" => true}]}), + 1000 + ) + + Server.receive_packet(server, request(4, "setExceptionBreakpoints", %{"filters" => []})) + assert_receive(response(_, 4, "setExceptionBreakpoints", %{})) + + 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 + }), + 500 + + assert_receive event(_, "thread", %{ + "reason" => "exited", + "threadId" => ^thread_id + }), + 5000 + end) + end + + test "notifies about mix task exit", %{server: server} do + in_fixture(__DIR__, "mix_project", fn -> + Server.receive_packet(server, initialize_req(1, %{})) + assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true})) + + Server.receive_packet( + server, + launch_req(2, %{ + "request" => "launch", + "type" => "mix_task", + "task" => "run", + "taskArgs" => ["-e", "MixProject.exit_self()"], + "projectDir" => File.cwd!() + }) + ) + + assert_receive(response(_, 2, "launch", %{}), 5000) + assert_receive(event(_, "initialized", %{})) + + Server.receive_packet( + server, + set_breakpoints_req(3, %{"path" => "lib/mix_project.ex"}, [%{"line" => 29}]) + ) + + assert_receive( + response(_, 3, "setBreakpoints", %{"breakpoints" => [%{"verified" => true}]}) + ) + + Server.receive_packet(server, request(4, "setExceptionBreakpoints", %{"filters" => []})) + assert_receive(response(_, 4, "setExceptionBreakpoints", %{})) + + 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 + }) + + assert_receive event(_, "thread", %{ + "reason" => "exited", + "threadId" => ^thread_id + }), + 5000 + + assert_receive event(_, "exited", %{ + "exitCode" => 1 + }) + + assert_receive event(_, "terminated", %{ + "restart" => false + }) end) end diff --git a/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/mix_project.ex b/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/mix_project.ex index 8022a0c0e..4d82bd0a9 100644 --- a/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/mix_project.ex +++ b/apps/elixir_ls_debugger/test/fixtures/mix_project/lib/mix_project.ex @@ -6,4 +6,26 @@ defmodule MixProject do def double(y) do 2 * y end + + def exit do + Task.start(fn -> + Task.start_link(fn -> + Process.sleep(1000) + raise ArgumentError + end) + + Process.sleep(:infinity) + end) + + Process.sleep(:infinity) + end + + def exit_self do + Task.start_link(fn -> + Process.sleep(1000) + raise ArgumentError + end) + + Process.sleep(:infinity) + end end diff --git a/apps/elixir_ls_utils/lib/output_device.ex b/apps/elixir_ls_utils/lib/output_device.ex index 48b1f1d3d..43591fd4e 100644 --- a/apps/elixir_ls_utils/lib/output_device.ex +++ b/apps/elixir_ls_utils/lib/output_device.ex @@ -1,7 +1,7 @@ defmodule ElixirLS.Utils.OutputDevice do @moduledoc """ Intercepts IO request messages and forwards them to the Output server to be sent as events to - the IDE. + the IDE. Implements Erlang I/O Protocol https://erlang.org/doc/apps/stdlib/io_protocol.html In order to send console output to Visual Studio Code, the debug adapter needs to send events using the usual wire protocol. In order to intercept the debugged code's output, we replace the @@ -10,65 +10,121 @@ defmodule ElixirLS.Utils.OutputDevice do server with the correct category ("stdout" or "stderr"). """ - use GenServer + @opts binary: true, encoding: :unicode ## Client API - def start_link(device, output_fn, opts \\ []) do - GenServer.start_link(__MODULE__, {device, output_fn}, opts) + def start_link(device, output_fn) do + Task.start_link(fn -> loop({device, output_fn}) end) end - ## Server callbacks + def get_opts, do: @opts - @impl GenServer - def init({device, output_fn}) do - {:ok, {device, output_fn}} - end + ## Implementation + + defp loop(state) do + receive do + {:io_request, from, reply_as, request} -> + result = io_request(request, state, reply_as) + send(from, {:io_reply, reply_as, result}) - @impl GenServer - def handle_info({:io_request, from, reply_as, {:put_chars, _encoding, characters}}, s) do - output(from, reply_as, characters, s) - {:noreply, s} + loop(state) + end end - @impl GenServer - def handle_info({:io_request, from, reply_as, {:put_chars, characters}}, s) do - output(from, reply_as, characters, s) - {:noreply, s} + defp send_to_output(encoding, characters, {_device, output_fn}) do + # convert to unicode binary if necessary + case wrap_characters_to_binary(characters, encoding) do + binary when is_binary(binary) -> + output_fn.(binary) + + _ -> + {:error, :put_chars} + end end - @impl GenServer - def handle_info({:io_request, from, reply_as, {:put_chars, _encoding, module, func, args}}, s) do - output(from, reply_as, apply(module, func, args), s) - {:noreply, s} + defp io_request({:put_chars, encoding, characters}, state, _reply_as) do + send_to_output(encoding, characters, state) end - @impl GenServer - def handle_info({:io_request, from, reply_as, {:put_chars, module, func, args}}, s) do - output(from, reply_as, apply(module, func, args), s) - {:noreply, s} + defp io_request({:put_chars, encoding, module, func, args}, state, _reply_as) do + # apply mfa to get binary or list + # return error in other cases + try do + case apply(module, func, args) do + characters when is_list(characters) or is_binary(characters) -> + send_to_output(encoding, characters, state) + + _ -> + {:error, :put_chars} + end + catch + _, _ -> {:error, :put_chars} + end end - @impl GenServer - def handle_info({:io_request, from, reply_as, {:requests, reqs}}, s) do - for req <- reqs do - handle_info({:io_request, from, reply_as, req}, s) + defp io_request({:requests, list}, state, reply_as) do + # process request sequentially until error or end of data + # return last result + case io_requests(list, {:ok, :ok}, state, reply_as) do + :ok -> :ok + {:error, error} -> {:error, error} + other -> {:ok, other} end + end + + defp io_request(:getopts, _state, _reply_as) do + @opts + end + + defp io_request({:setopts, new_opts}, _state, _reply_as) do + validate_otps(new_opts, {:ok, 0}) + end + + defp io_request(unknown, {device, _output_fn}, reply_as) do + # forward requests to underlying device + send(device, {:io_request, self(), reply_as, unknown}) - {:noreply, s} + receive do + {:io_reply, ^reply_as, reply} -> reply + end end - # Any other message (get_geometry, set_opts, etc.) goes directly to original device - @impl GenServer - def handle_info(msg, {device, _} = s) do - send(device, msg) - {:noreply, s} + defp io_requests(_, {:error, error}, _, _), do: {:error, error} + + defp io_requests([request | rest], _, state, reply_as) do + result = io_request(request, state, reply_as) + io_requests(rest, result, state, reply_as) end - ## Helpers + defp io_requests([], result, _, _), do: result + + defp wrap_characters_to_binary(bin, :unicode) when is_binary(bin), do: bin + + defp wrap_characters_to_binary(chars, from) do + # :unicode.characters_to_binary may throw, return error or incomplete result + try do + case :unicode.characters_to_binary(chars, from, :unicode) do + bin when is_binary(bin) -> + bin - defp output(from, reply_as, characters, {_, output_fn}) do - output_fn.(IO.iodata_to_binary(characters)) - send(from, {:io_reply, reply_as, :ok}) + _ -> + :error + end + catch + _, _ -> :error + end + end + + defp validate_otps([opt | rest], {:ok, acc}) do + validate_otps(rest, opt_valid?(opt, acc)) end + + defp validate_otps([], {:ok, 2}), do: :ok + defp validate_otps(_, _acc), do: {:error, :enotsup} + + defp opt_valid?(:binary, acc), do: {:ok, acc + 1} + defp opt_valid?({:binary, true}, acc), do: {:ok, acc + 1} + defp opt_valid?({:encoding, :unicode}, acc), do: {:ok, acc + 1} + defp opt_valid?(_opt, _acc), do: :error end diff --git a/apps/elixir_ls_utils/lib/wire_protocol.ex b/apps/elixir_ls_utils/lib/wire_protocol.ex index 030872a9f..866bd5add 100644 --- a/apps/elixir_ls_utils/lib/wire_protocol.ex +++ b/apps/elixir_ls_utils/lib/wire_protocol.ex @@ -10,19 +10,12 @@ defmodule ElixirLS.Utils.WireProtocol do pid = io_dest() body = JasonVendored.encode_to_iodata!(packet) - case IO.binwrite(pid, [ - "Content-Length: ", - IO.iodata_length(body) |> Integer.to_string(), - @separator, - body - ]) do - :ok -> - :ok - - {:error, reason} -> - IO.warn("Unable to write to the device: #{inspect(reason)}") - :ok - end + IO.binwrite(pid, [ + "Content-Length: ", + IO.iodata_length(body) |> Integer.to_string(), + @separator, + body + ]) end defp io_dest do @@ -36,10 +29,11 @@ defmodule ElixirLS.Utils.WireProtocol do def intercept_output(print_fn, print_err_fn) do raw_user = Process.whereis(:user) raw_standard_error = Process.whereis(:standard_error) - :ok = :io.setopts(raw_user, binary: true, encoding: :latin1) + + :ok = :io.setopts(raw_user, OutputDevice.get_opts()) {:ok, user} = OutputDevice.start_link(raw_user, print_fn) - {:ok, standard_error} = OutputDevice.start_link(raw_standard_error, print_err_fn) + {:ok, standard_error} = OutputDevice.start_link(raw_user, print_err_fn) Process.unregister(:user) Process.register(raw_user, :raw_user) diff --git a/apps/elixir_ls_utils/test/output_device_test.exs b/apps/elixir_ls_utils/test/output_device_test.exs new file mode 100644 index 000000000..8308c4de6 --- /dev/null +++ b/apps/elixir_ls_utils/test/output_device_test.exs @@ -0,0 +1,329 @@ +defmodule ElixirLS.Utils.OutputDeviceTest do + use ExUnit.Case, async: false + + alias ElixirLS.Utils.OutputDevice + + defmodule FakeOutput do + use GenServer + + def start_link(args) do + GenServer.start_link(__MODULE__, args, name: __MODULE__) + end + + def set_responses(responses) do + GenServer.call(__MODULE__, {:set_responses, responses}) + end + + def get_requests() do + GenServer.call(__MODULE__, :get_requests) + end + + @impl GenServer + def init(_) do + {:ok, {[], []}} + end + + @impl GenServer + def handle_call({:set_responses, responses}, _from, _state) do + {:reply, :ok, {[], responses}} + end + + def handle_call(:get_requests, _from, state = {requests, _}) do + {:reply, requests |> Enum.reverse(), state} + end + + @impl GenServer + def handle_info({:io_request, from, reply_as, req}, {requests, [resp | responses]}) do + send(from, {:io_reply, reply_as, resp}) + {:noreply, {[req | requests], responses}} + end + end + + defmodule FakeWireProtocol do + use GenServer + + def start_link(args) do + GenServer.start_link(__MODULE__, args, name: __MODULE__) + end + + def send(msg) do + GenServer.call(__MODULE__, {:send, msg}) + end + + def get() do + GenServer.call(__MODULE__, :get) + end + + @impl GenServer + def init(_), do: {:ok, []} + + @impl GenServer + def handle_call({:send, msg}, _from, state) do + {:reply, :ok, [msg | state]} + end + + def handle_call(:get, _from, state) do + {:reply, state |> Enum.reverse(), state} + end + end + + setup do + {:ok, fake_user} = FakeOutput.start_link([]) + {:ok, fake_wire_protocol} = FakeWireProtocol.start_link([]) + {:ok, output_device} = OutputDevice.start_link(fake_user, &FakeWireProtocol.send/1) + {:ok, output_device_error} = OutputDevice.start_link(fake_user, fn _ -> {:error, :emfile} end) + + {:ok, + %{ + output_device: output_device, + output_device_error: output_device_error, + fake_wire_protocol: fake_wire_protocol, + fake_user: fake_user + }} + end + + test "passes optional io_request to underlying device", %{ + output_device: output_device + } do + FakeOutput.set_responses([{:ok, 77}, {:error, :enotsup}, :ok]) + + send(output_device, {:io_request, self(), 123, {:get_geometry, :rows}}) + assert_receive({:io_reply, 123, {:ok, 77}}) + + send(output_device, {:io_request, self(), 123, {:get_geometry, :columns}}) + assert_receive({:io_reply, 123, {:error, :enotsup}}) + + send(output_device, {:io_request, self(), 123, :some_unknown}) + assert_receive({:io_reply, 123, :ok}) + + assert FakeOutput.get_requests() == [ + {:get_geometry, :rows}, + {:get_geometry, :columns}, + :some_unknown + ] + end + + describe "handles multi requests" do + test "all passed, all succeed, last response returned naked `:ok`", %{ + output_device: output_device + } do + FakeOutput.set_responses([:ok, {:ok, ""}, :eof, :ok]) + + requests = [ + :some, + :other1, + :other2, + :another + ] + + send(output_device, {:io_request, self(), 123, {:requests, requests}}) + assert_receive({:io_reply, 123, :ok}) + + assert FakeOutput.get_requests() == [:some, :other1, :other2, :another] + end + + test "all passed, all succeed, last response returned wrapped", %{ + output_device: output_device + } do + FakeOutput.set_responses([:ok, [:abc]]) + + requests = [ + {:other, [:abc]}, + :some + ] + + send(output_device, {:io_request, self(), 123, {:requests, requests}}) + assert_receive({:io_reply, 123, {:ok, [:abc]}}) + + assert FakeOutput.get_requests() == [{:other, [:abc]}, :some] + end + + test "all passed, error breaks processing, last response returned", %{ + output_device: output_device + } do + FakeOutput.set_responses([{:error, :notsup}]) + + requests = [ + {:get_geometry, :rows}, + :getopts + ] + + send(output_device, {:io_request, self(), 123, {:requests, requests}}) + assert_receive({:io_reply, 123, {:error, :notsup}}) + + assert FakeOutput.get_requests() == [{:get_geometry, :rows}] + end + end + + def get_chars_list("abc"), do: 'some' + def get_chars_binary("abc"), do: "some" + def get_chars_invalid("abc"), do: :some + def get_chars_raise("abc"), do: raise(ArgumentError) + def get_chars_throw("abc"), do: throw(:foo) + + describe "put_chars mfa" do + test "mfa list", %{ + output_device: output_device + } do + request = {:put_chars, :unicode, __MODULE__, :get_chars_list, ["abc"]} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, :ok}) + + assert FakeWireProtocol.get() == ["some"] + end + + test "mfa binary", %{ + output_device: output_device + } do + request = {:put_chars, :unicode, __MODULE__, :get_chars_binary, ["abc"]} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, :ok}) + + assert FakeWireProtocol.get() == ["some"] + end + + test "mfa invalid result", %{ + output_device: output_device + } do + request = {:put_chars, :unicode, __MODULE__, :get_chars_invalid, ["abc"]} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, {:error, :put_chars}}) + + assert FakeWireProtocol.get() == [] + end + + test "mfa throw", %{ + output_device: output_device + } do + request = {:put_chars, :unicode, __MODULE__, :get_chars_throw, ["abc"]} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, {:error, :put_chars}}) + + assert FakeWireProtocol.get() == [] + end + + test "mfa raise", %{ + output_device: output_device + } do + request = {:put_chars, :unicode, __MODULE__, :get_chars_raise, ["abc"]} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, {:error, :put_chars}}) + + assert FakeWireProtocol.get() == [] + end + end + + describe "put_chars" do + test "returns error from output function", %{ + output_device_error: output_device + } do + request = {:put_chars, :unicode, "sΔ…meπŸ‘¨β€πŸ‘©β€πŸ‘¦"} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, {:error, :emfile}}) + end + + test "unicode binary", %{ + output_device: output_device + } do + request = {:put_chars, :unicode, "sΔ…meπŸ‘¨β€πŸ‘©β€πŸ‘¦"} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, :ok}) + + assert FakeWireProtocol.get() == ["sΔ…meπŸ‘¨β€πŸ‘©β€πŸ‘¦"] + end + + test "unicode list", %{ + output_device: output_device + } do + request = {:put_chars, :unicode, 'sΔ…meπŸ‘¨β€πŸ‘©β€πŸ‘¦'} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, :ok}) + + assert FakeWireProtocol.get() == ["sΔ…meπŸ‘¨β€πŸ‘©β€πŸ‘¦"] + end + + test "latin1 binary", %{ + output_device: output_device + } do + request = {:put_chars, :latin1, "some"} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, :ok}) + + assert FakeWireProtocol.get() == ["some"] + end + + test "latin1 list", %{ + output_device: output_device + } do + request = {:put_chars, :latin1, 'some'} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, :ok}) + + assert FakeWireProtocol.get() == ["some"] + end + + test "latin1 list with chars > 255", %{ + output_device: output_device + } do + request = {:put_chars, :latin1, 'sΔ…meπŸ‘¨β€πŸ‘©β€πŸ‘¦'} + send(output_device, {:io_request, self(), 123, request}) + assert_receive({:io_reply, 123, {:error, :put_chars}}) + + assert FakeWireProtocol.get() == [] + end + end + + describe "opts" do + test "returns", %{ + output_device: output_device + } do + send(output_device, {:io_request, self(), 123, :getopts}) + assert_receive({:io_reply, 123, [binary: true, encoding: :unicode]}) + end + + test "valid can be set", %{ + output_device: output_device + } do + send( + output_device, + {:io_request, self(), 123, {:setopts, [:binary, {:encoding, :unicode}]}} + ) + + assert_receive({:io_reply, 123, :ok}) + + send( + output_device, + {:io_request, self(), 123, {:setopts, [{:encoding, :unicode}, {:binary, true}]}} + ) + + assert_receive({:io_reply, 123, :ok}) + end + + test "rejects invalid", %{ + output_device: output_device + } do + send(output_device, {:io_request, self(), 123, {:setopts, [:list, {:encoding, :unicode}]}}) + assert_receive({:io_reply, 123, {:error, :enotsup}}) + + send( + output_device, + {:io_request, self(), 123, {:setopts, [{:encoding, :latin1}, {:binary, true}]}} + ) + + assert_receive({:io_reply, 123, {:error, :enotsup}}) + + send( + output_device, + {:io_request, self(), 123, {:setopts, [:binary, {:encoding, :unicode}, {:some, :value}]}} + ) + + assert_receive({:io_reply, 123, {:error, :enotsup}}) + + send(output_device, {:io_request, self(), 123, {:setopts, [:binary]}}) + assert_receive({:io_reply, 123, {:error, :enotsup}}) + + send(output_device, {:io_request, self(), 123, {:setopts, [{:encoding, :unicode}]}}) + assert_receive({:io_reply, 123, {:error, :enotsup}}) + end + end +end diff --git a/apps/language_server/lib/language_server/json_rpc.ex b/apps/language_server/lib/language_server/json_rpc.ex index b60d5d1ff..27485c7f5 100644 --- a/apps/language_server/lib/language_server/json_rpc.ex +++ b/apps/language_server/lib/language_server/json_rpc.ex @@ -108,12 +108,12 @@ defmodule ElixirLS.LanguageServer.JsonRpc do end # Used to intercept :user/:standard_io output - def print(str) do + def print(str) when is_binary(str) do log_message(:log, String.replace_suffix(str, "\n", "")) end # Used to intercept :standard_error output - def print_err(str) do + def print_err(str) when is_binary(str) do log_message(:warning, String.replace_suffix(str, "\n", "")) end