Skip to content

Commit fce380f

Browse files
authored
feat!: be resilient to LSP protocol errors (#56)
Previously, if a user passed non compliant reply to a request, the framework would raise and your server would crash. Previously, if a client sent an invalid request or notification, the framework would raise, not providing a good error for the user or the LSP author. Now, in those three cases, if the framework sees a non-compliant response, it will respond to the client with a relevant error code and message. Note: in the case of notifications, we just log, as there is nothing to respond to.
1 parent d4e11f7 commit fce380f

File tree

3 files changed

+240
-59
lines changed

3 files changed

+240
-59
lines changed

lib/gen_lsp.ex

Lines changed: 169 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,54 @@ defmodule GenLSP do
44
|> String.split("<!-- MDOC !-->")
55
|> Enum.fetch!(1)
66

7+
defmodule InvalidRequest do
8+
defexception message: nil
9+
10+
@impl true
11+
def exception({request, errors}) do
12+
msg = """
13+
Invalid request from the client
14+
15+
Received: #{inspect(request)}
16+
Errors: #{inspect(errors)}
17+
"""
18+
19+
%__MODULE__{message: msg}
20+
end
21+
end
22+
23+
defmodule InvalidResponse do
24+
defexception message: nil
25+
26+
@impl true
27+
def exception({method, response, errors}) do
28+
msg = """
29+
Invalid response for request #{method}.
30+
31+
Response: #{inspect(response)}
32+
Errors: #{inspect(errors)}
33+
"""
34+
35+
%__MODULE__{message: msg}
36+
end
37+
end
38+
39+
defmodule InvalidNotification do
40+
defexception message: nil
41+
42+
@impl true
43+
def exception({notification, errors}) do
44+
msg = """
45+
Invalid notification from the client
46+
47+
Given: #{inspect(notification)}
48+
Errors: #{inspect(errors)}
49+
"""
50+
51+
%__MODULE__{message: msg}
52+
end
53+
end
54+
755
alias GenLSP.LSP
856

957
defmacro __using__(_) do
@@ -251,7 +299,7 @@ defmodule GenLSP do
251299
end
252300

253301
defp write_debug(device, event, name) do
254-
IO.write(device, "#{inspect(name)} event = #{inspect(event)}")
302+
IO.write(device, "#{inspect(name)} event = #{inspect(event)}\n")
255303
end
256304

257305
defp loop(%LSP{} = lsp, parent, deb) do
@@ -270,55 +318,108 @@ defmodule GenLSP do
270318
"Last message received: handle_request #{inspect(request)}",
271319
[:gen_lsp, :request, :client],
272320
fn ->
273-
{:ok, %{id: id} = req} = GenLSP.Requests.new(request)
274-
275-
result =
276-
:telemetry.span([:gen_lsp, :handle_request], %{method: req.method}, fn ->
277-
{lsp.mod.handle_request(req, lsp), %{}}
278-
end)
279-
280-
case result do
281-
{:reply, reply, %LSP{} = lsp} ->
282-
response_key =
283-
case reply do
284-
%GenLSP.ErrorResponse{} -> "error"
285-
_ -> "result"
286-
end
321+
case GenLSP.Requests.new(request) do
322+
{:ok, %{id: id} = req} ->
323+
result =
324+
:telemetry.span([:gen_lsp, :handle_request], %{method: req.method}, fn ->
325+
{lsp.mod.handle_request(req, lsp), %{}}
326+
end)
327+
328+
case result do
329+
{:reply, reply, %LSP{} = lsp} ->
330+
response_key =
331+
case reply do
332+
%GenLSP.ErrorResponse{} -> "error"
333+
_ -> "result"
334+
end
335+
336+
# if result is valid, continue, if not, we return an internal error
337+
{response_key, response} =
338+
case Schematic.dump(req.__struct__.result(), reply) do
339+
{:ok, output} ->
340+
{response_key, output}
341+
342+
{:error, errors} ->
343+
exception = InvalidResponse.exception({req.method, reply, errors})
344+
345+
Logger.error(Exception.format(:error, exception))
346+
347+
{:ok, output} =
348+
Schematic.dump(
349+
GenLSP.ErrorResponse.schematic(),
350+
%GenLSP.ErrorResponse{
351+
code: GenLSP.Enumerations.ErrorCodes.internal_error(),
352+
message: exception.message
353+
}
354+
)
355+
356+
{"error", output}
357+
end
358+
359+
packet = %{
360+
"jsonrpc" => "2.0",
361+
"id" => id,
362+
response_key => response
363+
}
364+
365+
deb =
366+
:sys.handle_debug(deb, &write_debug/3, __MODULE__, {:out, :request, from})
367+
368+
GenLSP.Buffer.outgoing(lsp.buffer, packet)
369+
370+
duration = System.system_time(:microsecond) - start
371+
372+
Logger.debug(
373+
"handled request client -> server #{req.method} in #{format_time(duration)}",
374+
id: req.id,
375+
method: req.method
376+
)
377+
378+
:telemetry.execute([:gen_lsp, :request, :client, :stop], %{duration: duration})
379+
380+
loop(lsp, parent, deb)
381+
382+
{:noreply, lsp} ->
383+
duration = System.system_time(:microsecond) - start
384+
385+
Logger.debug(
386+
"handled request client -> server #{req.method} in #{format_time(duration)}",
387+
id: req.id,
388+
method: req.method
389+
)
390+
391+
:telemetry.execute([:gen_lsp, :request, :client, :stop], %{duration: duration})
392+
393+
loop(lsp, parent, deb)
394+
end
395+
396+
{:error, errors} ->
397+
# the payload is not parseable at all, other than being valid JSON and having
398+
# an `id` property to signal its a request
399+
exception = InvalidRequest.exception({request, errors})
400+
401+
Logger.error(Exception.format(:error, exception))
402+
403+
{:ok, output} =
404+
Schematic.dump(
405+
GenLSP.ErrorResponse.schematic(),
406+
%GenLSP.ErrorResponse{
407+
code: GenLSP.Enumerations.ErrorCodes.invalid_request(),
408+
message: exception.message
409+
}
410+
)
287411

288412
packet = %{
289413
"jsonrpc" => "2.0",
290-
"id" => id,
291-
response_key => dump!(req.__struct__.result(), reply)
414+
"id" => request["id"],
415+
"error" => output
292416
}
293417

294418
deb =
295419
:sys.handle_debug(deb, &write_debug/3, __MODULE__, {:out, :request, from})
296420

297421
GenLSP.Buffer.outgoing(lsp.buffer, packet)
298422

299-
duration = System.system_time(:microsecond) - start
300-
301-
Logger.debug(
302-
"handled request client -> server #{req.method} in #{format_time(duration)}",
303-
id: req.id,
304-
method: req.method
305-
)
306-
307-
:telemetry.execute([:gen_lsp, :request, :client, :stop], %{duration: duration})
308-
309-
loop(lsp, parent, deb)
310-
311-
{:noreply, lsp} ->
312-
duration = System.system_time(:microsecond) - start
313-
314-
Logger.debug(
315-
"handled request client -> server #{req.method} in #{format_time(duration)}",
316-
id: req.id,
317-
method: req.method
318-
)
319-
320-
:telemetry.execute([:gen_lsp, :request, :client, :stop], %{duration: duration})
321-
322423
loop(lsp, parent, deb)
323424
end
324425
end
@@ -334,32 +435,45 @@ defmodule GenLSP do
334435
"Last message received: handle_notification #{inspect(notification)}",
335436
[:gen_lsp, :notification, :client],
336437
fn ->
337-
{:ok, note} = GenLSP.Notifications.new(notification)
438+
case GenLSP.Notifications.new(notification) do
439+
{:ok, note} ->
440+
result =
441+
:telemetry.span([:gen_lsp, :handle_notification], %{method: note.method}, fn ->
442+
{lsp.mod.handle_notification(note, lsp), %{}}
443+
end)
338444

339-
result =
340-
:telemetry.span([:gen_lsp, :handle_notification], %{method: note.method}, fn ->
341-
{lsp.mod.handle_notification(note, lsp), %{}}
342-
end)
445+
case result do
446+
{:noreply, %LSP{} = lsp} ->
447+
duration = System.system_time(:microsecond) - start
343448

344-
case result do
345-
{:noreply, %LSP{} = lsp} ->
346-
duration = System.system_time(:microsecond) - start
449+
Logger.debug(
450+
"handled notification client -> server #{note.method} in #{format_time(duration)}",
451+
method: note.method
452+
)
347453

348-
Logger.debug(
349-
"handled notification client -> server #{note.method} in #{format_time(duration)}",
350-
method: note.method
351-
)
454+
:telemetry.execute([:gen_lsp, :notification, :client, :stop], %{
455+
duration: duration
456+
})
352457

353-
:telemetry.execute([:gen_lsp, :notification, :client, :stop], %{
354-
duration: duration
355-
})
458+
loop(lsp, parent, deb)
459+
end
460+
461+
{:error, errors} ->
462+
# the payload is not parseable at all, other than being valid JSON
463+
exception = InvalidNotification.exception({notification, errors})
464+
465+
Logger.warning(Exception.format(:error, exception))
466+
467+
deb =
468+
:sys.handle_debug(deb, &write_debug/3, __MODULE__, {:out, :request, from})
356469

357470
loop(lsp, parent, deb)
358471
end
359472
end
360473
)
361474

362475
message ->
476+
deb = :sys.handle_debug(deb, &write_debug/3, __MODULE__, {:in, :info, message})
363477
start = System.system_time(:microsecond)
364478
:telemetry.execute([:gen_lsp, :info, :start], %{})
365479

test/gen_lsp_test.exs

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule GenLSPTest do
44
alias GenLSP.Structures
55

66
import GenLSP.Test
7+
import ExUnit.CaptureLog
78

89
setup do
910
server = server(GenLSPTest.ExampleServer, test_pid: self())
@@ -190,13 +191,12 @@ defmodule GenLSPTest do
190191

191192
assert :ok ==
192193
request(client, %{
193-
method: "textDocument/documentSymbol",
194+
method: "textDocument/definition",
194195
id: id,
195196
jsonrpc: "2.0",
196197
params: %{
197-
textDocument: %{
198-
uri: "file://file/doesnt/matter.ex"
199-
}
198+
position: %{line: 0, character: 0},
199+
textDocument: %{uri: "foo"}
200200
}
201201
})
202202

@@ -214,4 +214,67 @@ defmodule GenLSPTest do
214214

215215
refute alive?(server)
216216
end
217+
218+
test "returns an internal error and logs when the reply to a request is invalid", %{
219+
client: client
220+
} do
221+
id = System.unique_integer([:positive])
222+
223+
expected_msg =
224+
~S'''
225+
Invalid response for request textDocument/documentSymbol.
226+
227+
Response: [nil, []]
228+
Errors: "expected either either a list of a %GenLSP.Structures.SymbolInformation{}, a list of a %GenLSP.Structures.DocumentSymbol{}, or null or a %GenLSP.ErrorResponse{}"
229+
'''
230+
231+
assert :ok ==
232+
request(client, %{
233+
"jsonrpc" => "2.0",
234+
"method" => "initialize",
235+
"params" => %{"capabilities" => %{}},
236+
"id" => id
237+
})
238+
239+
log =
240+
capture_log(fn ->
241+
assert :ok ==
242+
request(client, %{
243+
"jsonrpc" => "2.0",
244+
"method" => "textDocument/documentSymbol",
245+
"params" => %{"textDocument" => %{"uri" => "foo"}},
246+
"id" => 2
247+
})
248+
249+
assert_error 2,
250+
%{
251+
"message" => msg,
252+
"code" => -32603
253+
},
254+
500
255+
256+
assert msg =~ expected_msg
257+
end)
258+
259+
assert log =~ expected_msg
260+
end
261+
262+
test "returns an invalid request when the paylaod is not parseable, but is still deemed a request",
263+
%{client: client} do
264+
assert :ok == request(client, %{"id" => "bingo", "random" => "stuff"})
265+
266+
assert_error "bingo",
267+
%{
268+
"message" => "Invalid request from the client" <> _,
269+
"code" => -32600
270+
},
271+
500
272+
end
273+
274+
test "logs when an invalid payload is received", %{client: client} do
275+
assert capture_log(fn ->
276+
assert :ok == notify(client, %{"random" => "stuff"})
277+
Process.sleep(100)
278+
end) =~ "Invalid notification from the client"
279+
end
217280
end

test/support/example_server.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ defmodule GenLSPTest.ExampleServer do
2727
}, lsp}
2828
end
2929

30+
def handle_request(%Requests.TextDocumentDocumentSymbol{}, lsp) do
31+
{:reply, [nil, []], lsp}
32+
end
33+
3034
def handle_request(_, lsp) do
3135
{:reply,
3236
%GenLSP.ErrorResponse{

0 commit comments

Comments
 (0)