Skip to content

Commit 32ab7ef

Browse files
authored
Merge pull request #30 from clojure-lsp/abort-cancelled-running-requests
Let language servers abort cancelled running requests
2 parents 01381e7 + aece0b6 commit 32ab7ef

File tree

4 files changed

+73
-10
lines changed

4 files changed

+73
-10
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Let language servers abort running requests that a client has cancelled.
6+
57
## v1.4.0
68

79
- Let language servers pick detail of traces, by setting `:trace-level`. #27

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,35 @@ These `defmethod`s receive 3 arguments, the method name, a "context", and the `p
4747
(conform-or-log ::coercer/location)))
4848
```
4949

50-
Notifications will block other requests and notifications. That is, lsp4clj won't read the next request or notification sent by a client until the language server returns from `lsp4clj.server/receive-notification`. By default, requests will block other messages too. That is, if a language server wants a request to block others, it should calculate and return the response in `lsp4clj.server/receive-request`. Otherwise, to allow the response to be calculated in parallel with others, it should return a `java.util.concurrent.CompletableFuture`, possibly created with `promesa.core/future`.
51-
5250
The return value of requests will be converted to camelCase json and returned to the client. If the return value looks like `{:error ...}`, it is assumed to indicate an error response, and the `...` part will be set as the `error` of a [JSON-RPC error object](https://www.jsonrpc.org/specification#error_object). It is up to you to conform the `...` object (by giving it a `code`, `message`, and `data`.) Otherwise, the entire return value will be set as the `result` of a [JSON-RPC response object](https://www.jsonrpc.org/specification#response_object). (Message ids are handled internally by lsp4clj.)
5351

52+
### Async requests
53+
54+
lsp4clj passes the language server the client's messages one at a time. It won't provide another message until it receives a result from the multimethods. Therefore, by default, requests and notifications are processed in series.
55+
56+
However, it's possible to calculate requests in parallel (though not notifications). If the language server wants a request to be calculated in parallel with others, it should return a `java.util.concurrent.CompletableFuture`, possibly created with `promesa.core/future`, from `lsp4clj.server/receive-request`. lsp4clj will arrange for the result of this future to be returned to the client when it resolves. In the meantime, lsp4clj will continue passing the client's messages to the language server. The language server can control the number of simultaneous messages by setting the parallelism of the CompletableFutures' executor.
57+
58+
### Cancelled inbound requests
59+
60+
Clients sometimes send `$/cancelRequest` notifications to indicate they're no longer interested in a request. If the request is being calculated in series, lsp4clj won't see the cancellation notification until after the response is already generated, so it's not possible to cancel requests that are processed in series.
61+
62+
But clients can cancel requests that are processed in parallel. In these cases lsp4clj will cancel the future and return a message to the client acknowledging the cancellation. Because of the design of CompletableFuture, cancellation can mean one of two things. If the executor hasn't started the thread that is calculating the value of the future (perhaps because the executor's thread pool is full), it won't be started. But if there is already a thread calculating the value, the thread won't be interupted. See the documentation for CompletableFuture for an explanation of why this is so.
63+
64+
Nevertheless, lsp4clj gives language servers a tool to abort cancelled requests. In the request's `context`, there will be a key `:lsp4clj.server/req-cancelled?` that can be dereffed to check if the request has been cancelled. If it has, then the language server can abort whatever it is doing. If it fails to abort, there are no consequences except that it will do more work than necessary.
65+
66+
```clojure
67+
(defmethod lsp4clj.server/receive-request "textDocument/semanticTokens/full"
68+
[_ {:keys [:lsp4clj.server/req-cancelled?] :as context} params]
69+
(promesa.core/future
70+
;; client may cancel request while we are waiting for analysis
71+
(wait-for-analysis context)
72+
(when-not @req-cancelled?
73+
(handler/semantic-tokens-full context params))))
74+
```
75+
5476
### Send messages
5577

56-
Servers also initiate their own requests and notifications to a client. To send a notification, call `lsp4clj.server/send-notification`.
78+
Servers also send their own requests and notifications to a client. To send a notification, call `lsp4clj.server/send-notification`.
5779

5880
```clojure
5981
(->> {:message message

src/lsp4clj/server.clj

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
[lsp4clj.lsp.responses :as lsp.responses]
99
[lsp4clj.protocols.endpoint :as protocols.endpoint]
1010
[lsp4clj.trace :as trace]
11-
[promesa.core :as p])
11+
[promesa.core :as p]
12+
[promesa.protocols])
1213
(:import
1314
(java.util.concurrent CancellationException)))
1415

@@ -159,6 +160,25 @@
159160
(when-let [trace-body (apply trace-f @tracer* params)]
160161
(async/put! trace-ch [:debug trace-body])))
161162

163+
(defrecord PendingReceivedRequest [result-promise cancelled?]
164+
promesa.protocols/ICancellable
165+
(-cancel! [_]
166+
(p/cancel! result-promise)
167+
(reset! cancelled? true))
168+
(-cancelled? [_]
169+
@cancelled?))
170+
171+
(defn pending-received-request [method context params]
172+
(let [cancelled? (atom false)
173+
;; coerce result/error to promise
174+
result-promise (p/promise
175+
(receive-request method
176+
(assoc context ::req-cancelled? cancelled?)
177+
params))]
178+
(map->PendingReceivedRequest
179+
{:result-promise result-promise
180+
:cancelled? cancelled?})))
181+
162182
;; TODO: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize
163183
;; * receive-request should return error until initialize request is received
164184
;; * receive-notification should drop until initialize request is received, with the exception of exit
@@ -240,10 +260,10 @@
240260
resp (lsp.responses/response id)]
241261
(try
242262
(trace this trace/received-request req started)
243-
;; coerce result/error to promise
244-
(let [result-promise (p/promise (receive-request method context params))]
245-
(swap! pending-received-requests* assoc id result-promise)
246-
(-> result-promise
263+
(let [pending-req (pending-received-request method context params)]
264+
(swap! pending-received-requests* assoc id pending-req)
265+
(-> pending-req
266+
:result-promise
247267
;; convert result/error to response
248268
(p/then
249269
(fn [result]
@@ -274,8 +294,8 @@
274294
(let [now (.instant clock)]
275295
(trace this trace/received-notification notif now)
276296
(if (= method "$/cancelRequest")
277-
(if-let [result-promise (get @pending-received-requests* (:id params))]
278-
(p/cancel! result-promise)
297+
(if-let [pending-req (get @pending-received-requests* (:id params))]
298+
(p/cancel! pending-req)
279299
(trace this trace/received-unmatched-cancellation-notification notif now))
280300
(let [result (receive-notification method context params)]
281301
(when (identical? ::method-not-found result)

test/lsp4clj/server_test.clj

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,25 @@
151151
(h/assert-take output-ch))))
152152
(server/shutdown server)))
153153

154+
(deftest should-inform-handler-when-request-is-cancelled
155+
(let [input-ch (async/chan 3)
156+
output-ch (async/chan 3)
157+
server (server/chan-server {:output-ch output-ch
158+
:input-ch input-ch})
159+
task-completed (promise)]
160+
(server/start server nil)
161+
(with-redefs [server/receive-request (fn [_method context _params]
162+
(p/future
163+
(Thread/sleep 300)
164+
(deliver task-completed
165+
(if @(:lsp4clj.server/req-cancelled? context)
166+
:cancelled
167+
:ran-anyway))))]
168+
(async/put! input-ch (lsp.requests/request 1 "initialize" {}))
169+
(async/put! input-ch (lsp.requests/notification "$/cancelRequest" {:id 1}))
170+
(is (= :cancelled (deref task-completed 1000 :timed-out))))
171+
(server/shutdown server)))
172+
154173
(deftest should-cancel-if-no-response-received
155174
(let [input-ch (async/chan 3)
156175
output-ch (async/chan 3)

0 commit comments

Comments
 (0)