Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: improve error handling for compiler diagnostics #165

Merged
merged 9 commits into from
Aug 13, 2023
2 changes: 1 addition & 1 deletion lib/next_ls/extensions/elixir_extension.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ defmodule NextLS.ElixirExtension do
end

@impl GenServer
def handle_info({:compiler, diagnostics}, state) do
def handle_info({:compiler, diagnostics}, state) when is_list(diagnostics) do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you'll need to handle an unhandled messaged, and emit a warning log

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just pushed some changes/fixes for this branch. At this point, I don't think we need to handle unexpected messages here because this should always be a list. Prior to these changes, if there was an RPC call failure, this might be like a :nodedown message or something, which was then causing a more confusing downstream error.

At this point, this guard is basically just a safety net so that, if an invalid message is sent, it's more obvious/clear in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgot to add: I'm fine either removing this guard or adding a catch-all for unhandled messages, but I personally think it's okay to be assertive here.

DiagnosticCache.clear(state.cache, :elixir)

for d <- diagnostics do
Expand Down
62 changes: 43 additions & 19 deletions lib/next_ls/runtime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ defmodule NextLS.Runtime do
use GenServer

@env Mix.env()
defguardp is_ready(state) when is_map_key(state, :node)

def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
Expand Down Expand Up @@ -125,10 +126,10 @@ defmodule NextLS.Runtime do
{:DOWN, ^ref, :process, ^me, reason} ->
case reason do
:shutdown ->
NextLS.Logger.log(logger, "The runtime for #{name} has successfully shutdown.")
NextLS.Logger.log(logger, "The runtime for #{name} has successfully shut down.")

reason ->
NextLS.Logger.error(logger, "The runtime for #{name} has crashed with reason: #{reason}.")
NextLS.Logger.error(logger, "The runtime for #{name} has crashed with reason: #{inspect(reason)}")
end
end
end)
Expand All @@ -144,8 +145,8 @@ defmodule NextLS.Runtime do
|> Path.join("monkey/_next_ls_private_compiler.ex")
|> then(&:rpc.call(node, Code, :compile_file, [&1]))
|> tap(fn
{:badrpc, :EXIT, {error, _}} ->
zachallaun marked this conversation as resolved.
Show resolved Hide resolved
NextLS.Logger.error(logger, error)
{:badrpc, error} ->
NextLS.Logger.error(logger, "Bad RPC call to node #{node}: #{inspect(error)}")

_ ->
:ok
Expand All @@ -155,9 +156,8 @@ defmodule NextLS.Runtime do

send(me, {:node, node})
else
_ ->
on_initialized.(:error)
send(me, :cancel)
error ->
send(me, {:cancel, error})
end
end)

Expand All @@ -180,35 +180,42 @@ defmodule NextLS.Runtime do
end

@impl GenServer
def handle_call(:ready?, _from, %{node: _node} = state) do
def handle_call(:ready?, _from, state) when is_ready(state) do
{:reply, true, state}
end

def handle_call(:ready?, _from, state) do
{:reply, false, state}
end

def handle_call(_, _from, state) when not is_ready(state) do
{:reply, {:error, :not_ready}, state}
end

def handle_call({:call, {m, f, a}}, _from, %{node: node} = state) do
reply = :rpc.call(node, m, f, a)
{:reply, {:ok, reply}, state}
end

def handle_call({:call, _}, _from, state) do
{:reply, {:error, :not_ready}, state}
end

def handle_call(:compile, from, %{node: node} = state) do
task =
Task.Supervisor.async_nolink(state.task_supervisor, fn ->
{_, errors} = :rpc.call(node, :_next_ls_private_compiler, :compile, [])
case :rpc.call(node, :_next_ls_private_compiler, :compile, []) do
{:badrpc, error} ->
NextLS.Logger.error(state.logger, "Bad RPC call to node #{node}: #{inspect(error)}")
[]

Registry.dispatch(state.registry, :extensions, fn entries ->
for {pid, _} <- entries do
send(pid, {:compiler, errors})
end
end)
{_, diagnostics} when is_list(diagnostics) ->
Registry.dispatch(state.registry, :extensions, fn entries ->
for {pid, _} <- entries, do: send(pid, {:compiler, diagnostics})
end)

errors
diagnostics

unknown ->
NextLS.Logger.warning(state.logger, "Unexpected compiler response: #{inspect(unknown)}")
[]
end
end)

{:noreply, %{state | compiler_ref: %{task.ref => from}}}
Expand All @@ -228,12 +235,29 @@ defmodule NextLS.Runtime do
{:noreply, %{state | compiler_ref: nil}}
end

def handle_info({:DOWN, _, :port, port, _}, %{port: port} = state) do
unless is_ready(state) do
state.on_initialized.({:error, :portdown})
end

{:stop, {:shutdown, :portdown}, state}
end

def handle_info({:cancel, error}, state) do
state.on_initialized.({:error, error})
{:stop, error, state}
end

def handle_info({:node, node}, state) do
Node.monitor(node, true)
state.on_initialized.(:ready)
{:noreply, Map.put(state, :node, node)}
end

def handle_info({:nodedown, node}, %{node: node} = state) do
{:stop, {:shutdown, :nodedown}, state}
end

def handle_info({port, {:data, data}}, %{port: port} = state) do
NextLS.Logger.log(state.logger, data)
{:noreply, state}
Expand Down
174 changes: 133 additions & 41 deletions test/next_ls/runtime_test.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
defmodule NextLs.RuntimeTest do
use ExUnit.Case, async: true

import ExUnit.CaptureLog
import NextLS.Support.Utils

alias NextLS.Runtime
Expand All @@ -23,11 +22,13 @@ defmodule NextLs.RuntimeTest do
end
""")

me = self()

{:ok, logger} =
Task.start_link(fn ->
recv = fn recv ->
receive do
msg -> Logger.debug(inspect(msg))
{:"$gen_cast", msg} -> send(me, msg)
end

recv.(recv)
Expand All @@ -36,68 +37,136 @@ defmodule NextLs.RuntimeTest do
recv.(recv)
end)

me = self()
on_init = fn msg -> send(me, msg) end

on_init = fn _ -> send(me, :ready) end
on_exit(&flush_messages/0)

[logger: logger, cwd: Path.absname(tmp_dir), on_init: on_init]
end

test "returns the response in an ok tuple", %{logger: logger, cwd: cwd, on_init: on_init} do
start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry})
tvisor = start_supervised!(Task.Supervisor)
describe "errors" do
test "emitted on crash during initialization",
%{tmp_dir: tmp_dir, logger: logger, cwd: cwd, on_init: on_init} do
# obvious syntax error
bad_mix_exs = String.replace(mix_exs(), "defmodule", "")
File.write!(Path.join(tmp_dir, "mix.exs"), bad_mix_exs)

start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry})

tvisor = start_supervised!(Task.Supervisor)

pid =
start_supervised!(
{Runtime,
task_supervisor: tvisor,
name: "my_proj",
on_initialized: on_init,
task_supervisor: tvisor,
working_dir: cwd,
uri: "file://#{cwd}",
parent: self(),
logger: logger,
db: :some_db,
registry: RuntimeTest.Registry}
registry: RuntimeTest.Registry},
restart: :temporary
)

Process.link(pid)
assert_receive {:error, :portdown}

assert_receive {:log, :log, log_msg}
assert log_msg =~ "syntax error"

assert_receive {:log, :error, error_msg}
assert error_msg =~ "{:shutdown, :portdown}"
end

test "emitted on crash after initialization",
%{logger: logger, cwd: cwd, on_init: on_init} do
start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry})

tvisor = start_supervised!(Task.Supervisor)

pid =
start_supervised!(
{Runtime,
task_supervisor: tvisor,
name: "my_proj",
on_initialized: on_init,
working_dir: cwd,
uri: "file://#{cwd}",
parent: self(),
logger: logger,
db: :some_db,
registry: RuntimeTest.Registry},
restart: :temporary
)

assert_receive :ready

assert wait_for_ready()
assert {:ok, {:badrpc, :nodedown}} = Runtime.call(pid, {System, :halt, [1]})

assert {:ok, "\"hi\""} = Runtime.call(pid, {Kernel, :inspect, ["hi"]})
assert_receive {:log, :error, error_msg}
assert error_msg =~ "{:shutdown, :nodedown}"
end
end

test "call returns an error when the runtime is node ready", %{logger: logger, cwd: cwd, on_init: on_init} do
start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry})
describe "call/2" do
test "responds with an ok tuple if the runtime has initialized",
%{logger: logger, cwd: cwd, on_init: on_init} do
start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry})
tvisor = start_supervised!(Task.Supervisor)

tvisor = start_supervised!(Task.Supervisor)
pid =
start_supervised!(
{Runtime,
name: "my_proj",
on_initialized: on_init,
task_supervisor: tvisor,
working_dir: cwd,
uri: "file://#{cwd}",
parent: self(),
logger: logger,
db: :some_db,
registry: RuntimeTest.Registry}
)

pid =
start_supervised!(
{Runtime,
task_supervisor: tvisor,
name: "my_proj",
on_initialized: on_init,
working_dir: cwd,
uri: "file://#{cwd}",
parent: self(),
logger: logger,
db: :some_db,
registry: RuntimeTest.Registry}
)
Process.link(pid)

assert_receive :ready

Process.link(pid)
assert {:ok, "\"hi\""} = Runtime.call(pid, {Kernel, :inspect, ["hi"]})
end

test "responds with an error when the runtime hasn't initialized", %{logger: logger, cwd: cwd, on_init: on_init} do
start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry})

tvisor = start_supervised!(Task.Supervisor)

pid =
start_supervised!(
{Runtime,
task_supervisor: tvisor,
name: "my_proj",
on_initialized: on_init,
working_dir: cwd,
uri: "file://#{cwd}",
parent: self(),
logger: logger,
db: :some_db,
registry: RuntimeTest.Registry}
)

assert {:error, :not_ready} = Runtime.call(pid, {IO, :puts, ["hi"]})
Process.link(pid)

assert {:error, :not_ready} = Runtime.call(pid, {IO, :puts, ["hi"]})
end
end

test "compiles the code and returns diagnostics", %{logger: logger, cwd: cwd, on_init: on_init} do
start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry})
describe "compile/1" do
test "compiles the project and returns diagnostics",
%{logger: logger, cwd: cwd, on_init: on_init} do
start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry})

tvisor = start_supervised!(Task.Supervisor)
tvisor = start_supervised!(Task.Supervisor)

capture_log(fn ->
pid =
start_link_supervised!(
{Runtime,
Expand All @@ -112,7 +181,7 @@ defmodule NextLs.RuntimeTest do
registry: RuntimeTest.Registry}
)

assert wait_for_ready()
assert_receive :ready

file = Path.join(cwd, "lib/bar.ex")

Expand Down Expand Up @@ -143,15 +212,38 @@ defmodule NextLs.RuntimeTest do
""")

assert [] == Runtime.compile(pid)
end) =~ "Connected to node"
end

test "responds with an error when the runtime isn't ready", %{logger: logger, cwd: cwd, on_init: on_init} do
start_supervised!({Registry, keys: :duplicate, name: RuntimeTest.Registry})

tvisor = start_supervised!(Task.Supervisor)

pid =
start_supervised!(
{Runtime,
task_supervisor: tvisor,
name: "my_proj",
on_initialized: on_init,
working_dir: cwd,
uri: "file://#{cwd}",
parent: self(),
logger: logger,
db: :some_db,
registry: RuntimeTest.Registry}
)

Process.link(pid)

assert {:error, :not_ready} = Runtime.compile(pid)
end
end

defp wait_for_ready do
defp flush_messages do
receive do
:ready -> true
_ -> flush_messages()
after
30_000 ->
false
0 -> :ok
end
end
end
2 changes: 1 addition & 1 deletion test/next_ls/workspaces_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ defmodule NextLS.WorkspacesTest do
}
})

message = "[NextLS] The runtime for #{context.module}-proj_two has successfully shutdown."
message = "[NextLS] The runtime for #{context.module}-proj_two has successfully shut down."

assert_notification "window/logMessage", %{
"message" => ^message
Expand Down
Loading
Loading