Skip to content

Commit 7f6e159

Browse files
authored
fix: handle document lifecycle notifications synchronously (#78)
1 parent 975efd3 commit 7f6e159

4 files changed

Lines changed: 230 additions & 75 deletions

File tree

lib/gen_lsp.ex

Lines changed: 120 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,19 @@ defmodule GenLSP do
165165
"""
166166
@callback handle_info(message :: any(), state) :: {:noreply, state} when state: GenLSP.LSP.t()
167167

168+
@default_sync_notifications [
169+
GenLSP.Notifications.TextDocumentDidOpen,
170+
GenLSP.Notifications.TextDocumentDidChange,
171+
GenLSP.Notifications.TextDocumentDidClose,
172+
GenLSP.Notifications.TextDocumentDidSave,
173+
GenLSP.Notifications.TextDocumentWillSave,
174+
GenLSP.Notifications.WorkspaceDidChangeWatchedFiles,
175+
GenLSP.Notifications.WorkspaceDidChangeWorkspaceFolders,
176+
GenLSP.Notifications.WorkspaceDidChangeConfiguration,
177+
GenLSP.Notifications.Initialized,
178+
GenLSP.Notifications.Exit
179+
]
180+
168181
@options_schema NimbleOptions.new!(
169182
buffer: [
170183
type: {:or, [:pid, :atom]},
@@ -182,6 +195,11 @@ defmodule GenLSP do
182195
type: :atom,
183196
doc:
184197
"Used for name registration as described in the \"Name registration\" section in the documentation for `GenServer`."
198+
],
199+
sync_notifications: [
200+
type: {:list, :atom},
201+
doc:
202+
"List of notification to process synchronously. Defaults to document lifecycle notifications."
185203
]
186204
)
187205

@@ -196,7 +214,8 @@ defmodule GenLSP do
196214
opts = NimbleOptions.validate!(opts, @options_schema)
197215

198216
:proc_lib.start_link(__MODULE__, :init, [
199-
{module, init_args, Keyword.take(opts, [:name, :buffer, :assigns, :task_supervisor]),
217+
{module, init_args,
218+
Keyword.take(opts, [:name, :buffer, :assigns, :task_supervisor, :sync_notifications]),
200219
self()}
201220
])
202221
end
@@ -207,14 +226,16 @@ defmodule GenLSP do
207226
buffer = opts[:buffer]
208227
assigns = opts[:assigns]
209228
task_supervisor = opts[:task_supervisor]
229+
sync_notifications = opts[:sync_notifications] || @default_sync_notifications
210230

211231
lsp = %LSP{
212232
mod: module,
213233
pid: me,
214234
buffer: buffer,
215235
assigns: assigns,
216236
task_supervisor: task_supervisor,
217-
tasks: Map.new()
237+
tasks: Map.new(),
238+
sync_notifications: MapSet.new(sync_notifications)
218239
}
219240

220241
case module.init(lsp, init_args) do
@@ -472,76 +493,22 @@ defmodule GenLSP do
472493
start = System.system_time(:microsecond)
473494
:telemetry.execute([:gen_lsp, :notification, :client, :start], %{})
474495

475-
attempt(
476-
lsp,
477-
"Last message received: handle_notification #{inspect(notification)}",
478-
[:gen_lsp, :notification, :client],
479-
fn
480-
{:error, _} ->
481-
Logger.warning("client -> server notification crashed")
482-
483-
_ ->
484-
case GenLSP.Notifications.new(notification) do
485-
{:ok, %GenLSP.Notifications.DollarCancelRequest{} = note} ->
486-
result =
487-
:telemetry.span(
488-
[:gen_lsp, :handle_notification],
489-
%{method: note.method},
490-
fn ->
491-
with pid when is_pid(pid) <- lsp.tasks[note.params.id] do
492-
Task.Supervisor.terminate_child(lsp.task_supervisor, pid)
493-
end
494-
495-
{{:noreply, lsp}, %{}}
496-
end
497-
)
498-
499-
case result do
500-
{:noreply, %LSP{}} ->
501-
duration = System.system_time(:microsecond) - start
502-
503-
Logger.debug(
504-
"handled notification client -> server #{note.method} in #{format_time(duration)}",
505-
method: note.method
506-
)
507-
508-
:telemetry.execute([:gen_lsp, :notification, :client, :stop], %{
509-
duration: duration
510-
})
511-
end
512-
513-
{:ok, note} ->
514-
result =
515-
:telemetry.span(
516-
[:gen_lsp, :handle_notification],
517-
%{method: note.method},
518-
fn ->
519-
{lsp.mod.handle_notification(note, lsp), %{}}
520-
end
521-
)
522-
523-
case result do
524-
{:noreply, %LSP{}} ->
525-
duration = System.system_time(:microsecond) - start
526-
527-
Logger.debug(
528-
"handled notification client -> server #{note.method} in #{format_time(duration)}",
529-
method: note.method
530-
)
531-
532-
:telemetry.execute([:gen_lsp, :notification, :client, :stop], %{
533-
duration: duration
534-
})
535-
end
496+
case GenLSP.Notifications.new(notification) do
497+
{:ok, %GenLSP.Notifications.DollarCancelRequest{} = note} ->
498+
handle_cancel_request(lsp, note, start)
536499

537-
{:error, errors} ->
538-
# the payload is not parseable at all, other than being valid JSON
539-
exception = InvalidNotification.exception({notification, errors})
500+
{:ok, note} ->
501+
if MapSet.member?(lsp.sync_notifications, note.__struct__) do
502+
handle_notification_sync(lsp, note, start)
503+
else
504+
handle_notification_async(lsp, note, start)
505+
end
540506

541-
Logger.warning(Exception.format(:error, exception))
542-
end
543-
end
544-
)
507+
{:error, errors} ->
508+
# the payload is not parseable at all, other than being valid JSON
509+
exception = InvalidNotification.exception({notification, errors})
510+
Logger.warning(Exception.format(:error, exception))
511+
end
545512

546513
loop(lsp, parent, deb)
547514

@@ -603,6 +570,89 @@ defmodule GenLSP do
603570
end)
604571
end
605572

573+
defp handle_cancel_request(lsp, note, start) do
574+
result =
575+
:telemetry.span([:gen_lsp, :handle_notification], %{method: note.method}, fn ->
576+
with pid when is_pid(pid) <- lsp.tasks[note.params.id] do
577+
Task.Supervisor.terminate_child(lsp.task_supervisor, pid)
578+
end
579+
580+
{{:noreply, lsp}, %{}}
581+
end)
582+
583+
case result do
584+
{:noreply, %LSP{}} ->
585+
duration = System.system_time(:microsecond) - start
586+
587+
Logger.debug(
588+
"handled notification client -> server #{note.method} in #{format_time(duration)}",
589+
method: note.method
590+
)
591+
592+
:telemetry.execute([:gen_lsp, :notification, :client, :stop], %{duration: duration})
593+
end
594+
end
595+
596+
defp handle_notification_sync(lsp, note, start) do
597+
try do
598+
result =
599+
:telemetry.span([:gen_lsp, :handle_notification], %{method: note.method}, fn ->
600+
{lsp.mod.handle_notification(note, lsp), %{}}
601+
end)
602+
603+
case result do
604+
{:noreply, %LSP{}} ->
605+
duration = System.system_time(:microsecond) - start
606+
607+
Logger.debug(
608+
"handled notification client -> server #{note.method} in #{format_time(duration)}",
609+
method: note.method
610+
)
611+
612+
:telemetry.execute([:gen_lsp, :notification, :client, :stop], %{duration: duration})
613+
end
614+
rescue
615+
e ->
616+
:telemetry.execute([:gen_lsp, :notification, :client, :exception], %{
617+
message: "Last message received: handle_notification #{inspect(note)}"
618+
})
619+
620+
message = Exception.format(:error, e, __STACKTRACE__)
621+
Logger.error(message)
622+
error(lsp, message)
623+
end
624+
end
625+
626+
defp handle_notification_async(lsp, note, start) do
627+
attempt(
628+
lsp,
629+
"Last message received: handle_notification #{inspect(note)}",
630+
[:gen_lsp, :notification, :client],
631+
fn
632+
{:error, _} ->
633+
:ok
634+
635+
_ ->
636+
result =
637+
:telemetry.span([:gen_lsp, :handle_notification], %{method: note.method}, fn ->
638+
{lsp.mod.handle_notification(note, lsp), %{}}
639+
end)
640+
641+
case result do
642+
{:noreply, %LSP{}} ->
643+
duration = System.system_time(:microsecond) - start
644+
645+
Logger.debug(
646+
"handled notification client -> server #{note.method} in #{format_time(duration)}",
647+
method: note.method
648+
)
649+
650+
:telemetry.execute([:gen_lsp, :notification, :client, :stop], %{duration: duration})
651+
end
652+
end
653+
)
654+
end
655+
606656
defp dump!(schematic, structure) do
607657
{:ok, output} = Schematic.dump(schematic, structure)
608658
output

lib/gen_lsp/lsp.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule GenLSP.LSP do
1111
field :pid, pid()
1212
field :tasks, %{integer() => pid()}
1313
field :task_supervisor, atom() | pid()
14+
field :sync_notifications, MapSet.t(module())
1415
end
1516

1617
@spec assign(t(), Keyword.t() | (map() -> keyword())) :: t()

test/gen_lsp_test.exs

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ defmodule GenLSPTest do
66
import GenLSP.Test
77
import ExUnit.CaptureLog
88

9-
setup do
10-
server = server(GenLSPTest.ExampleServer, test_pid: self())
11-
client = client(server)
12-
13-
[server: server, client: client]
9+
setup context do
10+
if context[:skip_setup] do
11+
:ok
12+
else
13+
server = server(GenLSPTest.ExampleServer, test_pid: self())
14+
client = client(server)
15+
16+
[server: server, client: client]
17+
end
1418
end
1519

1620
test "stores the user state and internal state", %{server: server} do
@@ -396,4 +400,57 @@ defmodule GenLSPTest do
396400
Process.sleep(100)
397401
end) =~ "Invalid notification from the client"
398402
end
403+
404+
@tag :skip_setup
405+
test "processes sync notifications in order" do
406+
{:ok, order_agent} = Agent.start_link(fn -> [] end)
407+
408+
server =
409+
server(GenLSPTest.OrderingServer,
410+
test_pid: self(),
411+
order_agent: order_agent
412+
)
413+
414+
client = client(server)
415+
416+
notify(client, %{
417+
"jsonrpc" => "2.0",
418+
"method" => "textDocument/didOpen",
419+
"params" => %{
420+
"textDocument" => %{
421+
"uri" => "file://somefile",
422+
"languageId" => "elixir",
423+
"version" => 1,
424+
"text" => "hello world!"
425+
}
426+
}
427+
})
428+
429+
notify(client, %{
430+
"jsonrpc" => "2.0",
431+
"method" => "textDocument/didChange",
432+
"params" => %{
433+
"textDocument" => %{
434+
"uri" => "file://somefile",
435+
"version" => 2
436+
},
437+
"contentChanges" => [%{"text" => "updated content"}]
438+
}
439+
})
440+
441+
notify(client, %{
442+
"jsonrpc" => "2.0",
443+
"method" => "textDocument/didClose",
444+
"params" => %{
445+
"textDocument" => %{
446+
"uri" => "file://somefile"
447+
}
448+
}
449+
})
450+
451+
assert_receive {:callback, %Notifications.TextDocumentDidOpen{}}
452+
assert_receive {:callback, %Notifications.TextDocumentDidChange{}}
453+
assert_receive {:callback, %Notifications.TextDocumentDidClose{}}
454+
assert Agent.get(order_agent, & &1) == [:did_open, :did_change, :did_close]
455+
end
399456
end

test/support/ordering_server.ex

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
defmodule GenLSPTest.OrderingServer do
2+
use GenLSP
3+
alias GenLSP.Notifications
4+
alias GenLSP.Requests
5+
alias GenLSP.Structures
6+
7+
def start_link(opts) do
8+
{test_pid, opts} = Keyword.pop!(opts, :test_pid)
9+
{order_agent, opts} = Keyword.pop!(opts, :order_agent)
10+
GenLSP.start_link(__MODULE__, {test_pid, order_agent}, opts)
11+
end
12+
13+
@impl true
14+
def init(lsp, {test_pid, order_agent}) do
15+
{:ok, assign(lsp, test_pid: test_pid, order_agent: order_agent)}
16+
end
17+
18+
@impl true
19+
def handle_request(%Requests.Initialize{}, lsp) do
20+
{:reply,
21+
%Structures.InitializeResult{
22+
capabilities: %Structures.ServerCapabilities{},
23+
server_info: %{name: "Ordering Test Server"}
24+
}, lsp}
25+
end
26+
27+
@impl true
28+
def handle_notification(%Notifications.TextDocumentDidOpen{} = note, lsp) do
29+
Agent.update(assigns(lsp).order_agent, fn list -> list ++ [:did_open] end)
30+
send(assigns(lsp).test_pid, {:callback, note})
31+
{:noreply, lsp}
32+
end
33+
34+
@impl true
35+
def handle_notification(%Notifications.TextDocumentDidChange{} = note, lsp) do
36+
Agent.update(assigns(lsp).order_agent, fn list -> list ++ [:did_change] end)
37+
send(assigns(lsp).test_pid, {:callback, note})
38+
{:noreply, lsp}
39+
end
40+
41+
@impl true
42+
def handle_notification(%Notifications.TextDocumentDidClose{} = note, lsp) do
43+
Agent.update(assigns(lsp).order_agent, fn list -> list ++ [:did_close] end)
44+
send(assigns(lsp).test_pid, {:callback, note})
45+
{:noreply, lsp}
46+
end
47+
end

0 commit comments

Comments
 (0)