Skip to content

Commit dc9c8f1

Browse files
committed
Allow the engine to start asynchronously
It can take many seconds until the engine is available, hence editors might time out the initialization. We still at least send an error $window/logMessage with the initialization error when it happens.
1 parent 4e099f7 commit dc9c8f1

File tree

5 files changed

+178
-92
lines changed

5 files changed

+178
-92
lines changed

apps/expert/lib/expert.ex

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
defmodule Expert do
2+
alias Expert.Project
23
alias Expert.Protocol.Convert
34
alias Expert.Protocol.Id
45
alias Expert.Provider.Handlers
@@ -51,27 +52,36 @@ defmodule Expert do
5152

5253
def handle_request(%GenLSP.Requests.Initialize{} = request, lsp) do
5354
state = assigns(lsp).state
54-
Process.send_after(self(), :default_config, :timer.seconds(5))
5555

5656
with {:ok, response, state} <- State.initialize(state, request),
5757
{:ok, response} <- Expert.Protocol.Convert.to_lsp(response) do
58-
lsp = assign(lsp, state: state)
58+
config = state.configuration
5959

60-
{:reply, response, lsp}
60+
Task.Supervisor.start_child(:expert_task_queue, fn ->
61+
Logger.info("Starting project at uri #{config.project.root_uri}")
62+
63+
start_result = Project.Supervisor.start(config.project)
64+
65+
send(Expert, {:engine_initialized, start_result})
66+
end)
67+
68+
{:reply, response, assign(lsp, state: state)}
6169
else
62-
{:error, {:shutdown, {:failed_to_start_child, _, _}}} ->
63-
{:reply,
64-
%GenLSP.ErrorResponse{
65-
code: GenLSP.Enumerations.ErrorCodes.server_not_initialized(),
66-
message: "Failed to start node"
67-
}, lsp}
70+
{:error, :already_initialized} ->
71+
response = %GenLSP.ErrorResponse{
72+
code: GenLSP.Enumerations.ErrorCodes.invalid_request(),
73+
message: "Already initialized"
74+
}
75+
76+
{:reply, response, lsp}
6877

6978
{:error, reason} ->
70-
{:reply,
71-
%GenLSP.ErrorResponse{
72-
code: GenLSP.Enumerations.ErrorCodes.server_not_initialized(),
73-
message: to_string(reason)
74-
}, lsp}
79+
response = %GenLSP.ErrorResponse{
80+
code: GenLSP.Enumerations.ErrorCodes.server_not_initialized(),
81+
message: "Failed to initialize: #{inspect(reason)}"
82+
}
83+
84+
{:reply, response, lsp}
7585
end
7686
end
7787

@@ -178,7 +188,7 @@ defmodule Expert do
178188
end
179189
end
180190

181-
def handle_info(:engine_initialized, lsp) do
191+
def handle_info({:engine_initialized, {:ok, _pid}}, lsp) do
182192
state = assigns(lsp).state
183193

184194
new_state = %{state | engine_initialized?: true}
@@ -190,20 +200,10 @@ defmodule Expert do
190200
{:noreply, lsp}
191201
end
192202

193-
def handle_info(:default_config, lsp) do
194-
state = assigns(lsp).state
195-
196-
if state.configuration == nil do
197-
Logger.warning(
198-
"Did not receive workspace/didChangeConfiguration notification after 5 seconds. " <>
199-
"Using default settings."
200-
)
203+
def handle_info({:engine_initialized, {:error, reason}}, lsp) do
204+
GenLSP.error(get_lsp(), initialization_error_message(reason))
201205

202-
{:ok, config} = State.default_configuration(state)
203-
{:noreply, assign(lsp, state: %{state | configuration: config})}
204-
else
205-
{:noreply, lsp}
206-
end
206+
{:noreply, lsp}
207207
end
208208

209209
defp apply_to_state(%State{} = state, %{} = request_or_notification) do
@@ -278,4 +278,33 @@ defmodule Expert do
278278
register_options: %Structures.DidChangeWatchedFilesRegistrationOptions{watchers: watchers}
279279
}
280280
end
281+
282+
defp initialization_error_message({:shutdown, {:failed_to_start_child, child, {reason, _}}}) do
283+
case child do
284+
{Project.Node, node_name} ->
285+
node_initialization_message(node_name, reason)
286+
287+
child ->
288+
"Failed to start child #{inspect(child)}: #{inspect(reason)}"
289+
end
290+
end
291+
292+
defp initialization_error_message(reason) do
293+
"Failed to initialize: #{inspect(reason)}"
294+
end
295+
296+
defp node_initialization_message(name, reason) do
297+
case reason do
298+
# NOTE:
299+
# ** (Mix.Error) httpc request failed with: ... Could not install Hex because Mix could not download metadata ...
300+
{:shutdown, {:error, :normal, message}} ->
301+
"Node #{name} shutdown with error:\n\n#{message}"
302+
303+
{:shutdown, {:node_exit, node_exit}} ->
304+
"Node #{name} exit with status #{node_exit.status}, last message:\n\n#{node_exit.last_message}"
305+
306+
reason ->
307+
"Failed to start node #{name}: #{inspect(reason)}"
308+
end
309+
end
281310
end

apps/expert/lib/expert/engine_node.ex

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,14 @@ defmodule Expert.EngineNode do
5050
# If we start distribution manually after all the code is loaded,
5151
# everything works fine.
5252
"""
53-
{:ok, _} = Node.start(:"#{Project.node_name(state.project)}", :longnames)
53+
node_start = Node.start(:"#{Project.node_name(state.project)}", :longnames)
5454
#{Forge.NodePortMapper}.register()
55-
IO.puts(\"ok\")
55+
case node_start do
56+
{:ok, _} ->
57+
IO.puts(\"ok\")
58+
{:error, reason} ->
59+
IO.puts(\"error starting node:\n \#{inspect(reason)}\")
60+
end
5661
"""
5762
| path_append_arguments(paths)
5863
]
@@ -107,16 +112,22 @@ defmodule Expert.EngineNode do
107112
end
108113

109114
def on_exit_status(%__MODULE__{} = state, exit_status) do
110-
if exit_status > 0 do
111-
GenLSP.error(
112-
Expert.get_lsp(),
113-
"Node exited with status #{exit_status}, last message: #{to_string(state.last_message)}"
114-
)
115-
else
116-
Logger.debug("Node exited with status 0")
117-
end
115+
Logger.debug(
116+
"Node exited with status #{exit_status}, last message: #{to_string(state.last_message)}"
117+
)
118118

119-
%{state | status: :stopped}
119+
stop_reason =
120+
case exit_status do
121+
0 ->
122+
:shutdown
123+
124+
_error_status ->
125+
{:shutdown, {:node_exit, %{status: exit_status, last_message: state.last_message}}}
126+
end
127+
128+
new_state = %{state | status: :stopped}
129+
130+
{stop_reason, new_state}
120131
end
121132

122133
def maybe_reply_to_stopper(%State{stopped_by: stopped_by} = state)
@@ -390,14 +401,16 @@ defmodule Expert.EngineNode do
390401

391402
@impl true
392403
def handle_info({_port, {:exit_status, exit_status}}, %State{} = state) do
393-
state = State.on_exit_status(state, exit_status)
404+
{stop_reason, state} = State.on_exit_status(state, exit_status)
394405

395-
{:stop, :shutdown, state}
406+
{:stop, stop_reason, state}
396407
end
397408

398409
@impl true
399-
def handle_info({_port, {:data, message}}, %State{} = state) do
400-
Logger.debug("Node port message: #{to_string(message)}")
410+
def handle_info({_port, {:data, data}}, %State{} = state) do
411+
message = to_string(data)
412+
Logger.debug("Node port message: #{message}")
413+
401414
{:noreply, %{state | last_message: message}}
402415
end
403416

apps/expert/lib/expert/state.ex

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ defmodule Expert.State do
3535
%__MODULE__{}
3636
end
3737

38-
# TODO: this function has a side effect (starting the project supervisor)
39-
# that i think might be better off in the calling function
4038
def initialize(
4139
%__MODULE__{initialized?: false} = state,
4240
%Requests.Initialize{
@@ -50,25 +48,17 @@ defmodule Expert.State do
5048
end
5149

5250
config = Configuration.new(event.root_uri, event.capabilities, client_name)
53-
Logger.info("Starting project at uri #{config.project.root_uri}")
5451

55-
with {:ok, _pid} <- Project.Supervisor.start(config.project) do
56-
send(Expert, :engine_initialized)
57-
new_state = %__MODULE__{state | configuration: config, initialized?: true}
58-
response = initialize_result()
52+
response = initialize_result()
53+
new_state = %__MODULE__{state | configuration: config, initialized?: true}
5954

60-
{:ok, response, new_state}
61-
end
55+
{:ok, response, new_state}
6256
end
6357

6458
def initialize(%__MODULE__{initialized?: true}, %Requests.Initialize{}) do
6559
{:error, :already_initialized}
6660
end
6761

68-
def default_configuration(%__MODULE__{configuration: config}) do
69-
Configuration.default(config)
70-
end
71-
7262
def apply(%__MODULE__{initialized?: false}, request) do
7363
Logger.error("Received #{request.method} before server was initialized")
7464
{:error, :not_initialized}

apps/expert/test/expert/engine_node_test.exs

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ defmodule Expert.EngineNodeTest do
44

55
import Forge.Test.EventualAssertions
66
import Forge.Test.Fixtures
7-
import Expert.Test.Protocol.TransportSupport
87

98
use ExUnit.Case, async: false
109
use Patch
@@ -66,33 +65,19 @@ defmodule Expert.EngineNodeTest do
6665
assert_receive {:lsp_log, "Couldn't find an elixir executable for project" <> _}
6766
end
6867

69-
test "sends a message to client if exited with error code", %{project: project} do
70-
with_patched_transport()
71-
68+
test "shuts down with error message if exited with error code", %{project: project} do
7269
{:ok, _node_name, node_pid} = EngineNode.start(project)
7370

74-
project_alive? = project |> EngineNode.name() |> Process.whereis() |> Process.alive?()
75-
assert project_alive?
71+
Process.monitor(node_pid)
7672

7773
exit_status = 127
7874

7975
send(node_pid, {nil, {:exit_status, exit_status}})
8076

81-
error_message_type = GenLSP.Enumerations.MessageType.error()
77+
assert_receive {:DOWN, _ref, :process, ^node_pid, exit_reason}
8278

83-
assert_receive {:transport,
84-
%GenLSP.Notifications.WindowLogMessage{
85-
params: %GenLSP.Structures.LogMessageParams{
86-
type: ^error_message_type,
87-
message: message
88-
}
89-
}}
90-
91-
assert String.starts_with?(
92-
message,
93-
"Node exited with status #{exit_status}, last message:"
94-
)
95-
96-
assert_eventually Process.whereis(EngineNode.name(project)) == nil, :timer.seconds(5)
79+
assert {:shutdown, {:node_exit, node_exit}} = exit_reason
80+
assert %{status: ^exit_status, last_message: last_message} = node_exit
81+
assert is_binary(last_message)
9782
end
9883
end

apps/expert/test/expert_test.exs

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,96 @@
11
defmodule Expert.ExpertTest do
2-
use ExUnit.Case, async: true
2+
alias Forge.Test.Fixtures
3+
4+
use ExUnit.Case, async: false
35
use Patch
6+
use Forge.Test.EventualAssertions
7+
8+
require GenLSP.Test
9+
10+
import Expert.Test.Protocol.TransportSupport
411

5-
describe "handle_request/2" do
6-
test "it replies with server_not_initialized on initialization error" do
7-
reason = :something_bad
12+
defp start_expert do
13+
patch(System, :argv, fn -> ["--port", "0"] end)
814

9-
patch(Expert.State, :initialize, fn _state, _request ->
10-
{:error, reason}
11-
end)
15+
assert {:ok, _} = Application.ensure_all_started(:expert)
1216

13-
assigns = start_supervised!(GenLSP.Assigns, id: make_ref())
14-
GenLSP.Assigns.merge(assigns, %{state: %{}})
17+
on_exit(fn -> Application.stop(:expert) end)
18+
end
19+
20+
describe "server testing" do
21+
setup do
22+
start_expert()
1523

16-
initialize_request = %GenLSP.Requests.Initialize{id: 1}
17-
lsp = %GenLSP.LSP{mod: Expert, assigns: assigns}
24+
%{lsp: lsp} = :sys.get_state(Expert.Buffer)
25+
{:ok, port} = :inet.port(GenLSP.Buffer.comm_state(Expert.Buffer).lsocket)
1826

19-
{:reply, response, _lsp} = Expert.handle_request(initialize_request, lsp)
27+
expert = %{
28+
lsp: lsp,
29+
port: port
30+
}
2031

21-
message = to_string(reason)
22-
error_code = GenLSP.Enumerations.ErrorCodes.server_not_initialized()
32+
client = GenLSP.Test.client(expert)
2333

24-
assert %GenLSP.ErrorResponse{code: ^error_code, data: nil, message: ^message} = response
34+
[expert: expert, client: client]
2535
end
36+
37+
test "replies to initialize with expert info and capabilities", %{client: client} do
38+
id = System.unique_integer([:positive])
39+
40+
project = Fixtures.project()
41+
42+
root_uri = project.root_uri
43+
root_path = Forge.Project.root_path(project)
44+
45+
assert :ok ==
46+
GenLSP.Test.request(client, %{
47+
"id" => id,
48+
"jsonrpc" => "2.0",
49+
"method" => "initialize",
50+
"params" => %{
51+
"rootPath" => root_path,
52+
"rootUri" => root_uri,
53+
"capabilities" => %{},
54+
"workspaceFolders" => [
55+
%{
56+
uri: root_uri,
57+
name: root_path
58+
}
59+
]
60+
}
61+
})
62+
63+
GenLSP.Test.assert_result(^id, result, 500)
64+
65+
{:ok, initialize_result} =
66+
GenLSP.Requests.Initialize.result()
67+
|> Schematic.dump(Expert.State.initialize_result())
68+
69+
assert result == initialize_result
70+
end
71+
end
72+
73+
test "sends an error message on engine initialization error" do
74+
with_patched_transport()
75+
76+
assigns = start_supervised!(GenLSP.Assigns, id: make_ref())
77+
GenLSP.Assigns.merge(assigns, %{state: %{}})
78+
79+
lsp = %GenLSP.LSP{mod: Expert, assigns: assigns}
80+
81+
reason = :something_bad
82+
83+
assert {:noreply, ^lsp} = Expert.handle_info({:engine_initialized, {:error, reason}}, lsp)
84+
85+
error_message = "Failed to initialize: #{inspect(reason)}"
86+
error_message_type = GenLSP.Enumerations.MessageType.error()
87+
88+
assert_receive {:transport,
89+
%GenLSP.Notifications.WindowLogMessage{
90+
params: %GenLSP.Structures.LogMessageParams{
91+
type: ^error_message_type,
92+
message: ^error_message
93+
}
94+
}}
2695
end
2796
end

0 commit comments

Comments
 (0)