ADR 0093: The initialize handshake is not awaited¶
Status¶
Accepted
Context¶
ADR 0092 stopped the UI thread waiting for a language server to die, and named what was still wrong at the other end of the connection: it still waited for one to start.
ase_lsp_client_start spawned the server, sent initialize, and then
blocked in wait_for(client, &state, 3000) until the reply came. Because
onActivated() starts a buffer's server the first time you land on it,
that wait was paid on a keystroke: the first Ctrl+Tab to each buffer
measured 17–24 ms.
The average was not the problem. The timeout was. A cold clangd, a large project, a loaded machine — anything that made the handshake slow made the editor unresponsive for exactly that long, up to a three-second freeze on a keypress. Same shape as the two waits before it: a UI-thread block on a duration nobody controls, bounded by a constant someone guessed.
Decision¶
Send, and let the poll finish it¶
ase_lsp_client_start now spawns, sends initialize, and returns. The
reply is picked up by ase_lsp_client_poll — which already runs on a
200 ms timer for diagnostics — and the callback sends initialized and
marks the client ready. First activation measures 1.6 ms.
Everything sent before the handshake lands is queued¶
This is what keeps the change invisible to callers. A server may ignore
or reject anything sent before initialize completes, and the GUI calls
did_open immediately after start.
So send_message queues the framed body when the client is not ready,
and the initialize callback flushes the queue in order. did_open,
did_change, completion, hover and definition all keep working exactly
as before, whenever they are called. Only the handshake's own two
messages bypass the queue, plus shutdown, which goes out on a client
that may never have become ready.
Verified: a file opened with deliberate errors shows its diagnostics,
which can only happen if the queued did_open really reached the
server after the handshake.
A server that never answers is the poll's problem too¶
The 3000 ms timeout did not disappear, it moved. poll marks the client
dead if it is still unready after 3000 ms — without blocking anyone.
A missing server is caught earlier and more cheaply: the child exits
immediately, poll reads EOF, and the client dies within one 200 ms
tick. That is faster than the old synchronous path, which sat out the
full handshake timeout to reach the same conclusion.
Starting is a state, because it is one¶
The status bar shows clangd… while the handshake is in flight, then
clangd. Pretending a server is Running before it is would make the
indicator lie for exactly as long as the thing this ADR is about.
pollLsp() promotes Starting to Running on readiness, or to
Failed with a message naming the config key to check. Measured on a
bogus command: the failure appears as "fake-server not found — check
lang.c.lsp" in 2.16 ms, where it used to cost a 3-second freeze first.
Consequences¶
ase_lsp_client_start no longer returns NULL for a server that cannot
be executed. It returns NULL only when the spawn or an allocation
fails; an unexecutable command spawns fine and dies on the first poll.
The lifecycle test asserted the old contract and now asserts the new
one — that the client starts, is not ready, and stops being alive once
polled.
ase_lsp_client_is_ready() is new API, because a caller that reports
readiness in a status bar needs to distinguish "handshaking" from
"working", while a caller that just sends messages does not have to care.
Nothing in the LSP client blocks any more, which is why wait_for, its
WaitState, and sleep_ms are all gone from the file — the last one
had been the only reason it needed a platform-specific sleep at all.
Diagnostics for a just-opened file now appear up to one 200 ms poll later than before, since the handshake completes on that timer rather than inside the keypress. Trading a 20 ms freeze for 200 ms of extra latency on a background result is the right way round.