Skip to content

Commit 3e00840

Browse files
Spec compliance improvements (#399)
* Improve spec compliance when starting and shutting down add startup/shutdown tests * normalize handling of $/ messages * fix test crash * handle protocol errors in packet stream * increase spec compatibility in debugger * server should not send requests until client notifies initialized * Update apps/language_server/lib/language_server/server.ex Co-authored-by: Jason Axelson <axelson@users.noreply.github.com>
1 parent 0574847 commit 3e00840

File tree

9 files changed

+536
-77
lines changed

9 files changed

+536
-77
lines changed

apps/elixir_ls_debugger/lib/debugger/server.ex

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,33 @@ defmodule ElixirLS.Debugger.Server do
6464
end
6565

6666
@impl GenServer
67+
def handle_cast({:receive_packet, request(_, "disconnect") = packet}, state) do
68+
Output.send_response(packet, %{})
69+
{:noreply, state, {:continue, :disconnect}}
70+
end
71+
6772
def handle_cast({:receive_packet, request(_, _) = packet}, state) do
6873
try do
69-
{response_body, state} = handle_request(packet, state)
70-
Output.send_response(packet, response_body)
71-
{:noreply, state}
74+
if state.client_info == nil do
75+
case packet do
76+
request(_, "initialize") ->
77+
{response_body, state} = handle_request(packet, state)
78+
Output.send_response(packet, response_body)
79+
{:noreply, state}
80+
81+
request(_, command) ->
82+
raise ServerError,
83+
message: "invalidRequest",
84+
format: "Debugger request {command} was not expected",
85+
variables: %{
86+
"command" => command
87+
}
88+
end
89+
else
90+
{response_body, state} = handle_request(packet, state)
91+
Output.send_response(packet, response_body)
92+
{:noreply, state}
93+
end
7294
rescue
7395
e in ServerError ->
7496
Output.send_error_response(packet, e.message, e.format, e.variables)
@@ -112,11 +134,16 @@ defmodule ElixirLS.Debugger.Server do
112134
{:noreply, %{state | task_ref: nil}}
113135
end
114136

115-
# If we get the disconnect request from the client, we send :disconnect to the server so it will
137+
# If we get the disconnect request from the client, we continue with :disconnect so the server will
116138
# die right after responding to the request
117139
@impl GenServer
118-
def handle_info(:disconnect, state) do
119-
System.halt(0)
140+
def handle_continue(:disconnect, state) do
141+
unless Application.get_env(:elixir_ls_debugger, :test_mode) do
142+
System.halt(0)
143+
else
144+
Process.exit(self(), {:exit_code, 0})
145+
end
146+
120147
{:noreply, state}
121148
end
122149

@@ -129,10 +156,19 @@ defmodule ElixirLS.Debugger.Server do
129156

130157
## Helpers
131158

132-
defp handle_request(initialize_req(_, client_info), state) do
159+
defp handle_request(initialize_req(_, client_info), %{client_info: nil} = state) do
133160
{capabilities(), %{state | client_info: client_info}}
134161
end
135162

163+
defp handle_request(initialize_req(_, _client_info), _state) do
164+
raise ServerError,
165+
message: "invalidRequest",
166+
format: "Debugger request {command} was not expected",
167+
variables: %{
168+
"command" => "initialize"
169+
}
170+
end
171+
136172
defp handle_request(launch_req(_, config), state) do
137173
{_, ref} = spawn_monitor(fn -> initialize(config) end)
138174

@@ -293,11 +329,6 @@ defmodule ElixirLS.Debugger.Server do
293329
{%{"result" => inspect(result), "variablesReference" => 0}, state}
294330
end
295331

296-
defp handle_request(request(_, "disconnect"), state) do
297-
send(self(), :disconnect)
298-
{%{}, state}
299-
end
300-
301332
defp handle_request(continue_req(_, thread_id), state) do
302333
pid = state.threads[thread_id]
303334
state = remove_paused_process(state, pid)

apps/elixir_ls_debugger/test/debugger_test.exs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,69 @@ defmodule ElixirLS.Debugger.ServerTest do
2626
{:ok, %{server: server}}
2727
end
2828

29+
describe "initialize" do
30+
test "succeedes", %{server: server} do
31+
Server.receive_packet(server, initialize_req(1, %{"clientID" => "some_id"}))
32+
assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true}))
33+
assert :sys.get_state(server).client_info == %{"clientID" => "some_id"}
34+
end
35+
36+
test "fails when already initialized", %{server: server} do
37+
Server.receive_packet(server, initialize_req(1, %{"clientID" => "some_id"}))
38+
assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true}))
39+
Server.receive_packet(server, initialize_req(2, %{"clientID" => "some_id"}))
40+
41+
assert_receive(
42+
error_response(
43+
_,
44+
2,
45+
"initialize",
46+
"invalidRequest",
47+
"Debugger request {command} was not expected",
48+
%{"command" => "initialize"}
49+
)
50+
)
51+
end
52+
53+
test "rejects requests when not initialized", %{server: server} do
54+
Server.receive_packet(
55+
server,
56+
set_breakpoints_req(1, %{"path" => "lib/mix_project.ex"}, [%{"line" => 3}])
57+
)
58+
59+
assert_receive(
60+
error_response(
61+
_,
62+
1,
63+
"setBreakpoints",
64+
"invalidRequest",
65+
"Debugger request {command} was not expected",
66+
%{"command" => "setBreakpoints"}
67+
)
68+
)
69+
end
70+
end
71+
72+
describe "disconnect" do
73+
test "succeedes when not initialized", %{server: server} do
74+
Process.flag(:trap_exit, true)
75+
Server.receive_packet(server, request(1, "disconnect"))
76+
assert_receive(response(_, 1, "disconnect", %{}))
77+
assert_receive({:EXIT, ^server, {:exit_code, 0}})
78+
Process.flag(:trap_exit, false)
79+
end
80+
81+
test "succeedes when initialized", %{server: server} do
82+
Process.flag(:trap_exit, true)
83+
Server.receive_packet(server, initialize_req(1, %{"clientID" => "some_id"}))
84+
assert_receive(response(_, 1, "initialize", %{"supportsConfigurationDoneRequest" => true}))
85+
Server.receive_packet(server, request(2, "disconnect"))
86+
assert_receive(response(_, 2, "disconnect", %{}))
87+
assert_receive({:EXIT, ^server, {:exit_code, 0}})
88+
Process.flag(:trap_exit, false)
89+
end
90+
end
91+
2992
test "basic debugging", %{server: server} do
3093
in_fixture(__DIR__, "mix_project", fn ->
3194
Server.receive_packet(server, initialize_req(1, %{}))
@@ -182,6 +245,8 @@ defmodule ElixirLS.Debugger.ServerTest do
182245
end
183246

184247
test "Evaluate expression with OK result", %{server: server} do
248+
Server.receive_packet(server, initialize_req(1, %{}))
249+
185250
Server.receive_packet(
186251
server,
187252
gen_packet("1 + 2 + 3 + 4")
@@ -194,6 +259,8 @@ defmodule ElixirLS.Debugger.ServerTest do
194259

195260
@tag :capture_log
196261
test "Evaluate expression with ERROR result", %{server: server} do
262+
Server.receive_packet(server, initialize_req(1, %{}))
263+
197264
Server.receive_packet(
198265
server,
199266
gen_packet("1 = 2")
@@ -207,6 +274,8 @@ defmodule ElixirLS.Debugger.ServerTest do
207274
end
208275

209276
test "Evaluate expression with attempt to exit debugger process", %{server: server} do
277+
Server.receive_packet(server, initialize_req(1, %{}))
278+
210279
Server.receive_packet(
211280
server,
212281
gen_packet("Process.exit(self(), :normal)")
@@ -220,6 +289,8 @@ defmodule ElixirLS.Debugger.ServerTest do
220289
end
221290

222291
test "Evaluate expression with attempt to throw debugger process", %{server: server} do
292+
Server.receive_packet(server, initialize_req(1, %{}))
293+
223294
Server.receive_packet(
224295
server,
225296
gen_packet("throw(:goodmorning_bug)")
@@ -233,6 +304,8 @@ defmodule ElixirLS.Debugger.ServerTest do
233304
end
234305

235306
test "Evaluate expression which has long execution", %{server: server} do
307+
Server.receive_packet(server, initialize_req(1, %{}))
308+
236309
Server.receive_packet(
237310
server,
238311
gen_packet(":timer.sleep(10_000)")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
Application.put_env(:elixir_ls_debugger, :test_mode, true)
12
ExUnit.start(exclude: [pending: true])

apps/elixir_ls_utils/lib/launch.ex

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,18 @@ defmodule ElixirLS.Utils.Launch do
2424
end
2525

2626
def language_server_version do
27-
{:ok, vsn} = :application.get_key(:language_server, :vsn)
28-
vsn
27+
get_version(:language_server)
2928
end
3029

3130
def debugger_version do
32-
{:ok, vsn} = :application.get_key(:elixir_ls_debugger, :vsn)
33-
vsn
31+
get_version(:elixir_ls_debugger)
32+
end
33+
34+
defp get_version(app) do
35+
case :application.get_key(app, :vsn) do
36+
{:ok, version} -> List.to_string(version)
37+
:undefined -> "dev"
38+
end
3439
end
3540

3641
defp load_dot_config do

apps/elixir_ls_utils/lib/packet_stream.ex

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defmodule ElixirLS.Utils.PacketStream do
1414
case read_packet(pid) do
1515
:eof -> {:halt, :ok}
1616
{:error, reason} -> {:halt, {:error, reason}}
17-
packet -> {[packet], :ok}
17+
{:ok, packet} -> {[packet], :ok}
1818
end
1919
end,
2020
fn
@@ -28,7 +28,9 @@ defmodule ElixirLS.Utils.PacketStream do
2828
end
2929

3030
defp read_packet(pid) do
31-
header = read_header(pid)
31+
header =
32+
read_header(pid)
33+
|> validate_content_type
3234

3335
case header do
3436
:eof -> :eof
@@ -53,20 +55,77 @@ defmodule ElixirLS.Utils.PacketStream do
5355
if line == "" do
5456
header
5557
else
56-
[key, value] = String.split(line, ": ")
57-
read_header(pid, Map.put(header, key, value))
58+
case String.split(line, ": ") do
59+
[key, value] ->
60+
read_header(pid, Map.put(header, key, value))
61+
62+
_ ->
63+
{:error, :invalid_header}
64+
end
5865
end
5966
end
6067
end
6168

6269
defp read_body(pid, header) do
63-
%{"Content-Length" => content_length_str} = header
64-
body = IO.binread(pid, String.to_integer(content_length_str))
70+
case get_content_length(header) do
71+
{:ok, content_length} ->
72+
case IO.binread(pid, content_length) do
73+
:eof -> :eof
74+
{:error, reason} -> {:error, reason}
75+
body -> JasonVendored.decode(body)
76+
end
6577

66-
case body do
67-
:eof -> :eof
68-
{:error, reason} -> {:error, reason}
69-
body -> JasonVendored.decode!(body)
78+
other ->
79+
other
80+
end
81+
end
82+
83+
def get_content_length(%{"Content-Length" => content_length_str}) do
84+
case Integer.parse(content_length_str) do
85+
{l, ""} when l >= 0 -> {:ok, l}
86+
_ -> {:error, :invalid_content_length}
87+
end
88+
end
89+
90+
def get_content_length(_), do: {:error, :invalid_content_length}
91+
92+
@default_mime_type "application/vscode-jsonrpc"
93+
@default_charset "utf-8"
94+
95+
defp get_content_type(%{"Content-Type" => content_type}) do
96+
case String.split(content_type, ";") do
97+
[mime_type] ->
98+
{mime_type |> String.trim() |> String.downcase(), @default_charset}
99+
100+
[mime_type | parameters] ->
101+
maybe_charset =
102+
for parameter <- parameters,
103+
trimmed = parameter |> String.trim(),
104+
trimmed |> String.starts_with?("charset="),
105+
"charset=" <> value = trimmed,
106+
do: value |> String.replace("\"", "") |> String.downcase()
107+
108+
charset =
109+
case maybe_charset |> Enum.at(0) do
110+
nil -> @default_charset
111+
# backwards compatibility
112+
"utf8" -> @default_charset
113+
other -> other
114+
end
115+
116+
{mime_type |> String.trim() |> String.downcase(), charset}
70117
end
71118
end
119+
120+
defp get_content_type(%{}), do: {@default_mime_type, @default_charset}
121+
122+
def validate_content_type(header) when is_map(header) do
123+
if get_content_type(header) == {@default_mime_type, @default_charset} do
124+
header
125+
else
126+
{:error, :not_supported_content_type}
127+
end
128+
end
129+
130+
def validate_content_type(other), do: other
72131
end

0 commit comments

Comments
 (0)