Skip to content

ADR 0011: LSP client — process isolation, sync handshake, async everything else

Status

Accepted

Context

Spec section 4: "LSP client module, isolated process boundary via JSON-RPC over stdio — a misbehaving language server must never crash the editor." Section 7 phase 6 scopes this to diagnostics, completion, and go-to-definition — not full LSP spec coverage.

Decisions

1. One child process per client, real process isolation

ase_lsp_client_start forks/execs the language server and talks to it over two pipes (its stdin/stdout). No shared memory, no linking against a server library — the server can do anything, including crash, and the worst case is ase_lsp_client_is_alive going false. This is the same isolation model the spec calls for and the same reasoning as process-based (rather than in-process) plugin sandboxing would use, if this project had that — here it's mandatory, not a choice.

SIGPIPE is disabled process-wide (signal(SIGPIPE, SIG_IGN)) the first time a client starts. Without this, writing to a server that has already died raises SIGPIPE, whose default action kills the whole editor — exactly the failure mode this ADR exists to prevent. This is a global, not per-client, side effect; documented here since it's the kind of thing that's surprising to discover by accident later. Standard practice for any program that manages pipes to child processes it doesn't fully trust.

2. The initialize handshake is synchronous (bounded), everything else is async

ase_lsp_client_start blocks — polling with small sleeps, never a raw blocking read() — for up to 3 seconds waiting for the initialize response before returning. Simpler lifecycle: callers get back either a working client or NULL, never a client that might still be mid-setup. ase_lsp_client_stop does the same bounded wait for shutdown/exit (1 second) before force-killing the process — never left hanging on a server that won't cooperate.

Every other request (textDocument/completion, textDocument/definition) is fire-and-return: the callback fires later, from inside ase_lsp_client_poll(). Blocking Phase 2's GUI thread waiting on a process that might be slow, hung, or gone would violate "no perceptible input lag" far more visibly than a 3-second one-time startup delay does.

3. poll() mirrors the config hot-reload shape: non-blocking, timer-driven

ase_lsp_client_poll never blocks — it drains whatever's available from the non-blocking read end, tries to frame complete Content-Length-delimited JSON-RPC messages out of the accumulated buffer, and dispatches each. The intended integration (not built yet — see "Not done" below) is the same shape as EditorViewport's config poll timer (ADR 0008): call it periodically from the GUI, no threading, no locks, one clear place data crosses from "external process" to "editor state."

4. Diagnostics via callback, requests via callback — same idea, different arity

textDocument/publishDiagnostics is a server-initiated notification with no request behind it, so it gets a standing callback (ase_lsp_client_set_diagnostics_callback), set once. Completion and definition are request/response, so each call takes its own callback — ordinary async-request shape. Both deliver JSON as borrowed pointers, valid only for the duration of the callback, to avoid a whole class of "who owns this JSON tree" bugs; a caller that needs the data longer copies out of it.

5. Server-to-client requests are not implemented

If a server sends us a request (has both method and id — e.g. workspace/configuration), v1 doesn't respond. Per LSP semantics, an unanswered request degrades gracefully in a well-behaved server (it just never resolves on their end); this deliberately doesn't attempt the fuller LSP surface that would require answering server-initiated requests correctly. Malformed/unrecognized messages of any kind are logged nowhere and simply dropped — the framing layer, not this dispatch logic, is what has to be bulletproof against garbage.

6. POSIX only in v1 — Windows returns NULL cleanly, not a half-tested attempt

Async child-process I/O on Windows needs either overlapped I/O or a dedicated reader thread — meaningfully more machinery than any other platform branch this project has written so far (contrast: config path resolution, _mkdir, LoadLibrary — all a few lines each). Writing an under-tested attempt at that risks shipping something that's worse than an honest gap: silently-broken async I/O is a much worse failure mode than a clean, documented "not supported yet." ase_lsp_client_start detects this by simply having platform_spawn return false on Windows — every other function in the file is consequently unreachable there but still compiles (checked via the Windows CI job, which only builds, doesn't run this functionally). Revisit when Windows LSP support is an actual, prioritized goal, not before.

7. Tested against a fake language server, not a real one

core/tests/fixtures/fake_lsp_server.c is a ~100-line stand-in implementing just enough LSP to be indistinguishable from a real server for this client's purposes: replies to initialize, sends publishDiagnostics after didOpen, replies to textDocument/completion and textDocument/definition with fixed canned results, and handles shutdown/exit. This is a deliberate choice over depending on a real language server (e.g. clangd) being installed in every dev/CI environment — the same reasoning as the plugin host's test fixtures (ADR 0009): a real, working integration test that doesn't depend on what happens to be installed where it runs.

Not done (deliberately out of Phase 6's scope)

  • Not wired into the GUI — no editor UI shows diagnostics, a completion popup, or jumps to a definition yet. That's real UX work (squiggly underlines, a popup widget, cross-file navigation) that belongs with Phase 7 polish or its own pass, not implied by "the client module works." Tracked in docs/ROADMAP.md, matching how Phase 5's plugin host also isn't wired to a keybinding yet.
  • No textDocument/didChange — the client can open a document but can't tell the server about edits after that. Fine for a first request/response smoke test; a real editing session would need this before diagnostics/completion results reflect current buffer state.
  • No workspace-wide features (symbols, rename, references) — sticking to exactly what section 7 asked for.

Consequences

Everything above the framing layer (process_complete_messages) is platform-independent and fully tested without needing a live process at all beyond the fake server. Real language servers (clangd, pyright, etc.) should work as-is for the three features implemented — this hasn't been verified against one, only against the fake fixture and the LSP spec's documented message shapes.