Developer Tools · Editors and IDEs

JSON-RPC Framing and the LSP Initialize Handshake

How JSON-RPC messages are framed on the wire with a byte-counted header, which message shapes the protocol allows, and how the initialize exchange decides which features exist for the rest of the session.

A language server is an ordinary program that reads bytes from a pipe and writes bytes back. What separates it from a filter is that both directions carry concurrent, independently addressed requests, so the byte stream needs a message boundary that survives arbitrary content — including JSON strings that contain newlines encoded as escapes, and payloads large enough that a single read returns only part of one message. The protocol solves framing with an HTTP-shaped header block in which the body length is counted in bytes, and it solves addressing with JSON-RPC 2.0. Both decisions are load-bearing: nearly every transport-level failure observed in practice is a byte count that disagrees with the number of bytes actually written.

The frame is HTTP-shaped, not HTTP

Each message begins with a header section of name: value lines separated by CRLF, terminated by an empty line, followed immediately by the JSON body. The one required header is Content-Length, whose value is the length of the body in bytes; Content-Type may appear and defaults to UTF-8 JSON if omitted. The head is deliberately small: only the length is needed to find the end of the body, so the reader does not need to parse JSON to know where a message stops.

That last property is the entire design rationale. A reader cannot scan for a delimiter because JSON permits a literal newline only inside a string as an escape sequence, and even then the writer and reader must agree on whether the body was compact or pretty-printed. Counting bytes instead makes the parse independent of the payload’s formatting, and it lets a reader skip a message it does not understand without parsing it.

The corresponding hazard is that “bytes” is not “characters”. A Content-Length computed from the length of a string in a UTF-16 host language counts code units, while the body written to the socket is UTF-8 and may be up to four times larger for text outside ASCII. The mismatch is not detected anywhere: the reader waits for bytes that never arrive if the header overstates the length, or consumes the first bytes of the next message if it understates it. The second case is worse, because the stream stays desynchronised and every later message is garbage.

import json, struct

def read_message(r):
    headers = {}
    while True:
        line = r.readline()                      # only the head is line-oriented
        if line in (b"\r\n", b"\n", b""):
            break
        name, _, value = line.partition(b":")
        headers[name.strip().lower()] = value.strip()
    length = int(headers[b"content-length"])
    body = b""
    while len(body) < length:                    # a pipe may return partial reads
        chunk = r.read(length - len(body))
        if not chunk:
            raise EOFError("stream closed mid-message")
        body += chunk
    return json.loads(body.decode("utf-8"))

def write_message(w, payload):
    body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
    w.write(b"Content-Length: %d\r\n\r\n" % len(body) + body)
    w.flush()

Two implementation notes follow from this code. Header names are compared case-insensitively, because they are HTTP field names and clients vary; and the body must be assembled from a buffer that tolerates short reads, since pipes and sockets are free to return fewer bytes than requested. A reader built on a single read call works in testing and fails under load, when the operating system decides to deliver half a message.

Three message shapes and one ordering rule

JSON-RPC defines requests, responses, and notifications. A request carries a string method, a params value that is an object or array, and an id that is a string or a number; the response echoes that id exactly and carries either a result or an error, never both. A notification is the same minus the id, and receives no reply of any kind — not even on failure. The protocol uses notifications for the high-frequency traffic: document changes, diagnostic publication, progress reports, and log messages.

Ordering is where the model differs from plain RPC. Responses may arrive in any order, because the sender is allowed to process requests concurrently, and a client therefore cannot assume that a reply to the second request follows the reply to the first. Only the message transport is ordered, in the sense that bytes arrive in the order written; the logical exchanges are interleaved. This is why the id space is per-connection rather than per-method, and why the id that a server echoes must be the one it received rather than a re-serialization of it: a numeric id 1 and a string id "1" are different keys.

Batch arrays, which JSON-RPC 2.0 permits, are not part of the protocol’s contract. An implementation that emits an array of requests is relying on behaviour no participant is obliged to provide, and a strict peer will reject the message as an invalid request rather than splitting it.

Error codes that mean different things

The JSON-RPC reserved range carries the transport-level failures: parse error, invalid request, method not found, invalid parameters, and internal error, at fixed negative codes in the -32700 to -32603 range. Server-defined errors occupy -32000 to -32099. The protocol layers its own meanings into that space, and the distinctions matter more than the numbers: a request that was cancelled returns a cancellation error rather than a failure, a request whose result was invalidated by a document change returns a content-modified error rather than a stale answer, and a request sent before the server finished initializing returns a not-initialized error.

The content-modified case is the one that changes client design. Because documents can change between the moment a request is issued and the moment the server answers, a server is permitted to abandon work whose input no longer exists and report that the result is stale. A client that treats every error as a hard failure will surface spurious error messages during fast typing; the correct handling is to drop the result and, if the feature still matters, reissue the request against the current document version.

What the initialize request actually decides

The session begins with an initialize request from client to server. It carries the client’s process id (used by the server to detect an orphaned client and exit), client metadata, the workspace root — increasingly expressed as a list of workspace folders rather than the single deprecated root URI — any initialization options the server defines, and the client capabilities object. The server answers with its own capabilities and metadata; the client then sends an initialized notification, and normal traffic begins.

The capability objects are the whole extension mechanism: the protocol has no version negotiation and no feature flags beyond these nested objects. A client advertises what it can render and what it can handle, down to details such as whether it supports snippet syntax in completion insert text, whether it interprets diagnostic tags, and which position encodings it prefers. A server advertises what it can answer, often with per-feature option objects that carry trigger characters, whether the feature supports resolve, and whether work-done progress may be reported.

Before the response to initialize is sent, the rules are asymmetric and strict. The client must not send other requests or notifications, and the server must not send requests or notifications except for the out-of-band messages that exist precisely for this window: log messages, user-facing show-message notifications, telemetry, and the request asking the client to display a message. This is not ceremony. The server does not yet know the client’s capabilities, so any richer interaction would be guessing at what the peer supports.

Termination mirrors the handshake. The client sends a shutdown request, which the server answers with a null result and after which no new requests are accepted; then the client sends an exit notification, and the process should terminate with a success status if shutdown was received and a failure status otherwise. Servers that skip the shutdown step and exit on stream closure cannot distinguish a crashed client from a deliberate shutdown.

Static and dynamic registration

Capabilities advertised in the initialize response are static: they apply for the session and cannot be withdrawn. The alternative is dynamic registration, in which the server sends a request asking the client to register a method, with options that include a document selector — a set of language identifiers, URI schemes, and glob patterns — and the client replies with an identifier for the registration, which the server can later use to unregister.

Dynamic registration matters for two reasons. It allows a server to serve several languages or file types selectively, instead of claiming a capability for every document the client opens, and it allows capabilities to change mid-session when the server discovers a project configuration file or finishes indexing. The cost is state: a registration is a promise the client stores, and a server that registers a handler but crashes without unregistering leaves the client sending notifications to a dead process until the connection dies. Servers that register for document changes for every scheme must also expect the client to filter by selector before sending, which is the only place where the selector is enforced.

Misnegotiation and how it presents

Three failure patterns account for most handshake problems. A server that omits its document synchronization capability leaves the default of no synchronization in effect, so it receives no document text and answers every feature request against an empty model — the symptom is a server that starts cleanly and does nothing. A client that sends a request for a feature the server did not advertise gets a method-not-found error, which a permissive client hides and a strict one reports as a server bug. And a server that assumes optional parameters are present crashes on the first request that omits them, since capability objects are sparse by design and absent properties mean “not supported”, not “supported with defaults”.

The general rule the handshake encodes is that nothing is available unless both sides said so, and that saying so once at the start is cheaper than checking per message. Everything downstream — the change notifications described in Incremental Document Synchronization Between Client and Server, the diagnostic channel, and the semantic token stream — is conditioned on a capability that was either present in that exchange or never existed at all.

References

  1. Language Server Protocol Specification 3.17 — Microsoft
  2. JSON-RPC 2.0 Specification — JSON-RPC Working Group
  3. RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format — IETF
  4. RFC 9110: HTTP Semantics — IETF