ADR 0096: One server per language and project, not per buffer¶
Status¶
Accepted
Context¶
Every EditorViewport owned an AseLspClient. Open four files and four
language servers started, each spawning its own process, building its
own preamble, and indexing the same project independently.
onActivated() said so in a comment — "One project-wide server is the
real fix for N files meaning N servers" — and
ADR 0029 had left it as known future work.
What was missing was the size of it.
Measured, three buffers open (one C, two C++) against this repository:
| processes | total RSS | |
|---|---|---|
| before | 4 | 1601 MB |
The editor itself was 43 MB. On a real C++ project, where a single clangd is routinely 500 MB to 1 GB, six open files is an out-of-memory condition rather than an inefficiency.
A second thing was wrong and invisible: ase_lsp_client_start(argv,
nullptr) — the server was never told the project root. clangd then has
no anchor for compile_commands.json discovery or cross-file indexing.
Decision¶
The registry owns servers; buffers borrow them¶
LspRegistry keys a client by (language, project root) and hands the
same one to every buffer that asks. It lives on the window and is passed
to each viewport — the shape OutputPanel and CommandLine already use
for "there is one of these".
Root comes from project::rootFor(), the same walk Ctrl+P uses, so
"project" means one thing in the editor rather than two.
Measured after, same three buffers:
| processes | total RSS | |
|---|---|---|
| before | 4 | 1601 MB |
| after | 2 | 919 MB |
Two, because C and C++ are different languages and get different servers. That is correct, not a shortfall — and it no longer grows with the number of files.
Servers outlive buffers, so documents must be closed¶
A per-buffer client could be discarded whole. A shared one cannot: when
a buffer closes, its document has to go without the server going with
it. ase_lsp_client_did_close is new for exactly this, and the registry
refcounts users — the last buffer to let go is the one that stops the
server.
Verified by tracing the lifecycle: start (1 user) → reuse (2 users,
still one server) → keep on closing one → stop on closing the last.
The registry polls, and owns the state¶
One 200 ms timer for all servers, where each buffer used to bring its own. Because state now belongs to the server rather than the buffer, the registry drives it and pushes changes to every buffer using it: a buffer joining a server that is already up is told immediately, and a server that dies reports to all of its buffers at once.
Diagnostics dispatch to every user of a server; each filters by URI, as it already did. That filter stopped being defensive and became the routing.
QPointer, not a raw pointer¶
The registry and the viewports are both children of the window, and Qt's teardown order is not ours to assume. A raw pointer would dangle rather than null, and the viewport's destructor calls into it — a crash on exit, found by reasoning about ownership rather than by it happening.
Consequences¶
Memory no longer scales with open files. Ten C++ buffers now cost one server instead of ten. The remaining two servers are the price of supporting two languages at once.
clangd now gets a rootUri, so compile_commands.json discovery
and cross-file navigation work from the project root rather than from
whatever directory a file happens to sit in.
Buffers in different projects still get different servers, which is the point of keying on root: two checkouts of the same repository must not share an index.
A server is now shared state. A crash takes diagnostics away from every
buffer using it at once, where before it affected one — they are all
told, and the status bar shows stopped for each. Restarting a dead
server is still not implemented, in either design.
checkLspAlive() and the per-viewport pollLsp() are gone, along with
the per-buffer poll timer. The 750 ms config timer no longer carries a
liveness check it was only sharing a beat with.