Developer Tools · Editors and IDEs
Cancellation, Progress, and Partial Results in the Language Server Protocol
How a client abandons work that is no longer needed, how a server reports bounded progress on work that is, and why streaming results are framed so that the final reply carries nothing.
Requests in an editor are speculative. A completion request is issued because the user typed a character, and becomes worthless the moment another character arrives; a workspace-wide search for references is issued for a symbol the user may navigate away from mid-request. The protocol therefore provides three mechanisms that only make sense together: cancelling a request whose answer is no longer wanted, streaming a request’s results as they are produced so that a long operation is not all-or-nothing, and reporting progress on work that cannot be streamed. Each is defined by a small rule, and each of those rules exists to keep the client and the server from disagreeing about what has already been delivered.
Cancellation as a notification that may lose the race
Cancellation is sent as a notification naming the id of the request to abandon. It is not a request, receives no reply, and carries no information about why: the client does not distinguish an abandoned completion from a superseded search. The server is expected to stop working on the request if it can, and, if it cannot, to simply finish it.
Three consequences follow directly from the framing. First, cancellation races the response: the server may have already computed the answer and written it, so a client must be prepared to receive a normal result for a request it just cancelled, and must discard it rather than treat it as a leak. Second, the cancelled request still produces exactly one response, either a cancellation error or a result computed before the cancellation was seen, because every request must be answered, and a server that drops the exchange leaves the client’s pending map leaking entries. Third, the notification cannot be answered, so there is no way for the client to learn whether the cancellation took effect.
The id in the cancellation refers to the request id as sent, in the same type. Since ids may be strings or numbers and are compared exactly, an implementation that normalises ids (stringifying them, or converting numeric strings) will cancel the wrong request or fail to find the right one.
The single-thread deadline problem
The protocol deliberately permits an implementation to ignore messages whose method begins with the reserved prefix used for implementation-defined methods, and names the single-threaded server as the example: a server that reads a message, handles it synchronously to completion, and only then reads the next message will never observe a cancellation until the work it should stop is already finished.
Such a server has two available answers, and both are legitimate. It can check for pending cancellation between passes of its own algorithm, after each file in a project-wide operation for instance, by draining the read side of the connection between units of work, which requires the transport to be non-blocking or to be polled from inside the loop. Or it can accept the waste, finish the request, and let the client throw the answer away. The second costs CPU proportional to the work abandoned; the first costs a restructuring of the server around a loop that can be interrupted.
// Cooperative cancellation: the unit of work checks between passes.
func references(ctx context.Context, prog *Index, sym Symbol) ([]Loc, error) {
var out []Loc
for _, file := range prog.Files() {
if err := ctx.Err(); err != nil { // cancelled or deadline exceeded
return nil, err
}
out = append(out, prog.RefsIn(file, sym)...)
}
return out, nil
}
The shape of the loop determines how much work a cancellation can actually save. A pass that takes milliseconds gives a cancellation granularity of milliseconds, and any single unit longer than the frame budget makes the mechanism cosmetic. This is the same structure that incremental parsing needs when a new keystroke invalidates a parse in flight, described in Incremental Parsing and Tree Reuse in Editor Syntax Engines, and the same one an editor uses when a second completion request replaces the first.
On the client side, the handling of a cancelled request must be silent. The cancellation error and the late result are both discarded without user-visible output, and a feature that still matters, such as the completion list for the current prefix, is reissued against the current document version rather than patched from the stale answer.
Partial results and why the final reply is empty
Some requests produce a stream rather than a value: a symbol search across a large workspace may yield matches for minutes. Waiting for the complete set wastes the time already spent, so the request may carry a token indicating that partial results are accepted, and the server then sends those intermediate results as progress notifications of a distinct kind.
The rule that makes this unambiguous is counter-intuitive the first time it is read: when a server has reported partial results, the final response must carry no result items. Each partial notification appends to what has already been delivered, and if the final response also carried items, the client could not tell whether they replaced the earlier ones or extended them. By making the final response empty, the protocol removes the choice. A client that receives partials accumulates them, and the terminal response is a signal that the stream has ended.
The value carried by each partial notification is the same type as the final result, which means a client can apply the same rendering path to both. A server that sends partials of a different shape, flattened or with different field names, produces a client that either crashes or silently drops the intermediate data.
Partial delivery also interacts with cancellation and with versioning. A long reference search that is cancelled after three partials leaves the client with an incomplete set; the correct handling is to treat the accumulated set as provisional and to mark it as such, rather than presenting it as the complete answer for the symbol. And because partials arrive over time while the document may be edited, the closures in the results refer to ranges that may have moved, which is the ordinary staleness problem with an unusually long window.
Partly for this reason, cancellation matters more for some features than others. A request whose answer is a value can be abandoned freely; a request whose answer is a complete set, such as a document’s diagnostics, must not be answered with a truncated set once the work behind it has been abandoned, and that constraint shapes the delivery model described in Diagnostics Delivery: Push, Pull, and Stale Results.
Progress tokens, client- and server-initiated
Progress reporting uses a token that the two sides agree on, either an integer or a string, and a family of notifications with a begin phase, repeated report phases, and exactly one end phase. The token identifies which operation the notifications belong to, so several operations can report concurrently without the client confusing them.
The client initiates progress for a request it is about to send, by attaching a token to the request parameters; if the server supports reporting progress for that request, it sends begin, reports, and end against that token. The server initiates progress for work it starts on its own, such as indexing a workspace on startup, by first sending a request asking the client to create a progress item, and then using the returned token. Server-initiated progress requires the client to have advertised support for it. The token from such a create request is single-use: one begin, many reports, one end, and never a second operation on the same token.
Reports may carry a percentage, a message, and whether the operation can be cancelled; the percentage is optional because most operations cannot estimate their remaining work, and a client must handle an indeterminate progress item. The cancel path for progress is a separate notification from the client, and it applies to server-initiated progress that the user chose to abandon. A server that never sends the end phase leaves the client’s indicator spinning indefinitely, and since nothing else terminates a progress item, a missing end is indistinguishable from an operation that is still running.
The cost model is worth stating in the negative: for an operation that completes within a few hundred milliseconds, the begin/report/end exchange is more traffic and more client-side state than the operation deserves, and the typical visible effect is a progress indicator that flashes. Progress is worth sending when the work exceeds the interval at which a person notices that nothing is happening, and worth suppressing below it.
Concurrency limits and request storms
Nothing in the protocol bounds how many requests a client may have in flight, and the natural implementation (a request per keystroke, per selection change, per scroll) produces exactly that storm. The two costs are on the server, which must hold per-request state and compete for its own analysis locks, and on the connection, since every request and response occupies the same ordered stream as the document-change notifications that keep the analysis correct.
Two defences are standard. The client coalesces: for a feature whose answer depends on the current prefix, only the newest request matters, so earlier ones are cancelled as soon as they are superseded. The server bounds concurrency explicitly, either by queueing requests and answering the oldest, or by refusing work while a document analysis is in progress and replying with a content-modified error, which tells the client that the answer would have been stale anyway. Both defences depend on the content-modified error being handled as a normal outcome rather than as a fault, and on the cancellation path being cheap enough that cancelling does not cost as much as the work cancelled.
Failures that waste work silently
A server that treats cancellation as a request to stop sending, rather than as a request to stop computing, consumes the CPU it was supposed to save. A server that reuses a progress token for a second operation interleaves the two operations’ phases and leaves the client with an indicator that never ends. A client that surfaces cancellation errors to the user turns a routine superseded request into a visible error report. A server that sends partial results and also fills in the final response causes duplicated entries in a list that the user cannot distinguish from real duplicates.
Testing these paths is unpleasant for the same reason they are easy to get wrong: the races are timing-dependent, and the correct behaviour for both the cancelled and the completed case looks identical to an observer that only checks the final screen. The check that does discriminate is state accounting — every request id is answered exactly once, every progress token ends exactly once, and the sum of the partial results equals the result that a single uncancelled request would have produced.
References
- Language Server Protocol Specification 3.17 — Microsoft
- JSON-RPC 2.0 Specification — JSON-RPC Working Group
- Go context package documentation — Go project