Developer Tools · Editors and IDEs

Diagnostics Delivery: Push, Pull, and Stale Results

How errors and warnings travel from a language server to the editor: the push notification that owns a document, version binding, the pull request with result identifiers, and the ways a squiggle outlives the code it describes.

Diagnostics are the only protocol feature whose default direction is server to client without a request. Everything else in the protocol is either a request with a reply or a notification about a state change the client caused; errors and warnings arrive because the server decided the document has problems, at a time of its own choosing, possibly long after the edit that introduced them. That asymmetry (the server owns the content of the report and the timing of delivery, while the client owns the display and the decision of what remains visible) produces a set of failure modes distinct from any other channel in the ecosystem.

A notification with no reply channel

The push form is a notification carrying a document URI and an array of diagnostics. Each diagnostic has a range, a severity from four levels, an optional code and source, a message, optional related information pointing at other locations, and optional tags marking code as unnecessary or deprecated. There is no reply: the client cannot reject the report or ask for clarification, and the server learns nothing about whether it was displayed.

The array is complete, not cumulative. Each notification replaces the previous set for that URI, so publishing an empty array is how a server says the document is clean — and it is the only way. A server that stops reporting, because it crashed, was killed, or lost interest in the file, leaves its last set displayed indefinitely; the client has no timeout and no way to distinguish “still broken” from “no longer reporting”. Clearing on document close is the client’s responsibility, and servers are expected to clear their own reports when a document becomes ineligible for analysis.

Because the array is complete, its size is the cost of the protocol message, and the size is not bounded by the protocol. A file with a thousand warnings produces a notification in the hundreds of kilobytes, retransmitted on every reanalysis, including the reanalysis triggered by the edit that fixed one of them. Servers usually mitigate this by publishing only when the set changed, which requires keeping the previous set and comparing it, and by capping the number of diagnostics per document, which is why editors show a count with a “more problems” marker rather than an exhaustive list.

Version binding and the stale underline

The notification may carry the document version the diagnostics were computed for. Whether that number is used is a client capability: a client that does not interpret the version receives the field and ignores it, and the server must not assume anything from sending it.

Version binding exists because analysis is asynchronous. A server that debounces analysis, or that runs type checking on a background thread, can finish with a result for version 41 while the buffer is at version 44. Without a version, the client can only apply the ranges to whatever text is current, which moves the underline to a different token when edits earlier in the file shifted the offsets. With a version, a client can compare against the current version and either display the diagnostics as stale or suppress them for the few frames until the newer report arrives.

The subtlety is that suppression is not always the better choice. A diagnostic computed one keystroke ago is usually right about the remaining problem; discarding it produces flicker, where an error disappears while the cursor moves and reappears afterwards. Implementations typically apply the set and mark it as belonging to an older version internally, so that it can be replaced but not trusted for range-critical operations — for example, an edit derived from a diagnostic range must be revalidated against the current text before it is applied.

Pull diagnostics and the unchanged result

The pull form inverts the control: the client sends a request for a document’s diagnostics and the server answers, either with a full report containing the items and a result identifier, or with an unchanged report that carries only the identifier of the previously returned result. The identifier is opaque to the client and meaningful to the server; its purpose is to let the server skip retransmitting an identical set, which turns the cost of polling a clean, large file into a small reply.

A workspace-wide variant takes a list of previous result identifiers, one per document, and returns reports for many documents in one exchange. This is the mechanism that makes diagnostics for unopened files practical: a client can ask for the state of a whole project without opening every buffer, and servers that already indexed the project can answer from the index instead of parsing files on demand.

Three properties follow from the inversion. The client controls aggregate cost, since it decides when to ask and for which documents, which suits an editor that wants diagnostics only for visible files during typing and for the whole project during idle. Servers can answer from cached analysis without a timer, so a pull-based server does not need to guess how long to wait after a change. And problems that no request would surface, such as a diagnostic produced while indexing some other file, cannot be delivered at all in pull mode until something asks for that document, which is why the two models coexist rather than one replacing the other.

{ "jsonrpc": "2.0", "method": "textDocument/publishDiagnostics",
  "params": { "uri": "file:///src/lib.rs", "version": 44,
    "diagnostics": [ { "range": { "start": {"line": 12, "character": 8},
                                  "end":   {"line": 12, "character": 15} },
                       "severity": 1, "code": "E0308",
                       "source": "rustc", "message": "mismatched types" } ] } }

{ "jsonrpc": "2.0", "id": 7, "result":
  { "kind": "unchanged", "resultId": "sha256:5f0c…" } }

The two replies above are the same feature observed from opposite directions: a set pushed for a version, and a set explicitly not sent because nothing changed.

What the two models cost

Push costs per change and scales with the number of diagnostics, but it arrives quickly, and it can report on documents the client is not looking at. It also puts the responsibility for staleness on the client, since reports can arrive out of order relative to edits and relative to each other when more than one analysis pass is in flight.

Pull costs per request and scales with the number of documents polled, but each poll is cheap when nothing changed, and the reply is naturally associated with the request that asked for it, which removes most ordering ambiguity. It costs latency: a diagnostic cannot appear until a request is made, so a pull-only client must poll on edit, on idle, and on open, and must decide what interval to use.

In practice both are used in one session where the client supports it: pull for the ordinary case because it composes with caching, push for the reports that arrive from background work. The compatibility rule is that a client must not conflate the two — a pull response and a pushed set for the same document are both current, and the client has to decide which one it is displaying.

Clearing, ownership, and multiple servers

Ownership is per server, not per document. Two servers can report on the same file, a compiler-based server and a linter for example, and the protocol does not arbitrate between them; the client merges the sets, which is why diagnostics carry a source string and why an editor shows the source next to the message. A file that gains a second server therefore gains a second set of underlines, and a fix that only one server recognizes leaves the other’s warning in place, correctly.

Clearing has three cases that implementations routinely miss. A server that publishes an empty array when the file becomes clean is doing the minimum. A server that stops analysing a file, because it was closed or because the workspace folder was removed, must publish an empty array for it rather than simply ceasing to talk. And a server that restarts has no memory of what it previously published, so the client must drop the previous session’s sets on restart — otherwise the old squiggles remain alongside the new ones, attributed to the same source.

The connection between diagnostics and code actions adds a fourth constraint. A diagnostic may carry a data payload that the server expects to receive back when the client asks for code actions at that diagnostic, which lets the server avoid recomputing the analysis that produced the problem. That payload is preserved between the publish notification and the later request, so a client that reconstructs diagnostics from its own state rather than storing what it received breaks the round trip, and the fixes offered for a warning arrive without the context needed to compute them.

Debouncing and the latency budget

Analysis is expensive enough that servers wait for a pause in typing before running it, and the wait is a trade between promptness and wasted work. Too short, and every keystroke in a large file starts a full type check that is abandoned when the next keystroke arrives; too long, and the error under the cursor appears after the user has moved on.

The same trade appears on the client side when applying the results, and the mechanism for abandoning work is cancellation, described in Cancellation, Progress, and Partial Results in the Language Server Protocol. A cancelled analysis must not publish anything, including a partial set — a half-computed diagnostic list published as complete is worse than a delayed one, because the complete-array semantics mean it replaces the previous, accurate set with a truncated one.

The mirror that all of this depends on is described in Incremental Document Synchronization Between Client and Server: a server whose copy of the text has drifted reports problems in ranges that the client’s text no longer has, and the resulting underlines are attached to arbitrary characters. Stale diagnostics are a synchronization bug with a delayed symptom, which is why version numbers on the report are worth supporting even when the analysis itself is cheap.

References

  1. Language Server Protocol Specification 3.17 — Microsoft
  2. Visual Studio Code API documentation — Microsoft
  3. Using the GNU Compiler Collection (GCC) — Free Software Foundation