Skip to content

[inferno-lsp] Refactor LSP server to address several critical problems - #225

Merged
ngua merged 17 commits into
mainfrom
rory-fix-lsp
Jun 3, 2026
Merged

ngua merged 17 commits into
mainfrom
rory-fix-lsp

Conversation

@ngua

@ngua ngua commented May 29, 2026 •

Copy link
Copy Markdown
Contributor

This is essentially a complete rewrite of the inferno-lsp server to fix critical memory and performance issues. These really were serious enough to warrant a rewrite (see details below). The external API of the LSP is preserved (i.e. anything establishing a connection to the LSP). The only non-backwards-compatible changes are in the introduction of the LspConfig record instead of eleven positional arguments, but that's a trivial switch at a very small number of call sites for us.

NOTE: I did not change the version of lsp that we're using. I figured this introduced enough changes and updating would not be worth the additional churn or unexpected issues. So I've kept us on the same version as before for now.

Major issues in previous implementation

Unbounded hover map growth

This was by far the largest problem with the previous implementation, but it was also tied closely to other parts explained below. This problem alone warranted a significant rewrite IMO.

The old server built up hover entries across every document version into a single global map, keyed by (NormalizedUri, Int32). DidChange inserted new version keys without evicting old ones. This means the map grew without bound while the server ran. Even though the main entrypoint for Inferno LSP is one server-per-session, a normal, long-running editing session could accumulate enough memory in the main process address space to kill it (I mean without any adversarial intent, which is another story).

A long-running inferno-lsp server process could theoretically consume as much memory as available to the process, just for hovers! This, coupled with the poor parser performance and O(n^3) typechecking algorithm (both now fixed separately), is probably the largest single cause of the degenerate behavior we've seen from inferno-lsp.

Now we use a per-document (not global) TVar DocState inside a concurrent StmContainers.Map. Each document's hover state is replaced (not appended) on every successful analysis. DidClose cancels any current work and removes the document from the store entirely.

No debouncing or cancellation

Previously, every keystroke triggered a full parse/infer pipeline with no debounce and no way to cancel the previous task. During any actual editing this implied dozens (or more) of redundant pipeline runs piling up, each holding intermediate ASTs and type maps in memory until completion, with no way to cancel them. This is true for single documents, i.e. a single editing session could accumulate thousands of full parsing and inference jobs without any cancellation.

The new server implements per-document debounce and cancellation: each DidChange cancels the previous document's in-flight Async (which interrupts the threadDelay immediately since it's an interruptible operation), then spawns a new async that waits debounceMs (default 150ms) before running the pipeline. A version guard (whenCurrentVersion) prevents stale results from overwriting fresh diagnostics (possible race condition).

Eager findTypeClassWitnesses at parse time

The old server called findTypeClassWitnesses for every AST node in the type map during parsing which built markdown hover content eagerly. Finding typeclass witnesses can be an expensive combinatorial search that should only run for the single node the user is actually hovering over.

Now findTypeClassWitnesses is deferred to hover-request time only and runs it for the one queried node. The result is cached in an IntMap keyed by the linearized position so repeated hovers on the same position are not recomputed. The cache is invalidated on every analysis update since source positions shift.

O(n) hover lookup

The old code scanned the entire type map linearly on every hover request, checking whether the cursor position fell within each span. For large scripts with hundreds of type annotations this could be tangibly slow (I mean when actually using the script editor).

This is fixed with an IntervalMap with linearized source positions. Point queries are now O(log n). The smallest enclosing interval is selected to give the most specific (innermost) type.

Unsafe/partial list indexing in completion

completionQueryAt used Text.splitOn "\n" followed by !! fromIntegral line, which could crash on out-of-bounds positions or degenerate inputs.

The rewrite computes line starts via scanl' and uses a total drop and pattern-match with clamping to document length if the position lies beyond the end.

Reactor channel (unbounded work queue)

The old architecture serialized all notifications and requests through a single TChan ReactorInput. This queued hover requests behind stale DidChanges and the channel could grow without bound during bursts.

The reactor has been removed entirely. The lsp library itself already provides concurrency and handlers execute directly in LspT. Per-document asyncs are naturally serialized by the cancel-and-replace pattern, and read-only handlers (hover, completion) access current state immediately via STM with no need for queuing.

11 positional arguments to the server entry point

The old runInfernoLspServerWith took 11 positional arguments (!) (tracer, IO handles, module map, custom types, ident callback, validation callback, before/after parse callbacks, etc.). This was error-prone and hard to extend, and was also underdocumented

There's now an LspConfig c with named fields and sensible defaults. Callers supply a LspConfig c -> LspConfig c modifier to override only what they need. I kept runInfernoLspServer as a backwards-compatible wrapper that uses default config.

Make parseAndInferWithTimeout a real utility function

Previously, parseAndInferWithTimeout was linked to LSP-server lifecycle, dealt with document UUIDs, etc.... This was an overlapping of concerns: it really should just do what it says, i.e. parsing and inferring within a timeout. Calling it was awkward and required passing two const ... arguments (because they were always irrelevant) and then turning a four-tuple into a tuple. Again, this was because it was a "utility function" that was actually tied directly to an otherwise unrelated LSP server.

This has been addressed in the refactor -- it now only does exactly what it says. Call sites have been fixed.

Module reorganization

The previous implementation put almost everything in two files: Server.hs (handlers, types, initialization, reactor, diagnostics) and ParseInfer.hs (parsing, inference, hover rendering, completion, diagnostic conversion, and unused debugging utilities). I.e. there was no real separation of concerns.

Now there are focused modules with clear responsibilities:

  • DocState: Per-document state types, DocStore (STM map), lifecycle operations (open/close/update/cancel). No LSP or language logic
  • Hover: HoverIndex (interval map), linearization, point queries, hover content rendering (mkPrettyTy, metadataDocsText). No IO or STM
  • ParseInfer: Parse/infer pipeline orchestration and diagnostic conversion. No hover rendering, completions, or STM. It does not handle input validation (domain policy) or HoverIndex construction (both belong to the Server)
  • Completion: Completion prefix extraction, prelude/identifier/module/keyword filtering, CompletionItem construction. No IO or document state
  • Server: Actual LSP handlers, LspConfig/Env, debounce/cancel orchestration, afterParse callback integration, diagnostic publishing. Delegates all other domain logic to the modules above

ngua and others added 16 commits May 15, 2026 13:44
This is the groundwork for the LSP rewrite. It adds the STM primitives,
etc... that will be used for hovers (which is the most critical thing
that needs to be fixed), document state, and so on. Split into smaller
modules to avoid monolithic design

Also adds some helpful virtual record fields for key `inferno-core`
types; these aren't defined as records (although they could have been),
and pattern matching on them everywhere is tedious and difficult to read
IMO. Removes previous orphan instances for this purpose from `Infer.hs`
Adds the new `ParseInfer` module with some differences from the old one;
diagnostics are the same for the most part

- Replaces the anonymous `Either [Diagnostic] (Expr, TCScheme,
[Diagnostic], [(Range, MarkupContent)])` with an actual `InferSuccess`
record for clarity
- `parseAndInferDiagnostics` is now pure (no unnecessary `MonadIO m`
context; `liftIO` was never used anywhere and the interpreter is always
in `IO` anyway)
- Removed `validateInput` and hover rendering; these are documented as
server responsibilities now
- `nubOrd` for error dedup instead of more expensive
`Set.fromList`/`Set.toList` round-trips
- Dropped `parseAndInferPretty`, `parseAndInferTypeReps`,
`parseAndInferPossibleTypes`; AFAICT these were old debugging/testing
utilities (probably from original development) not needed in the LSP
server, so dropped
These largely resemble the old modules but with some fixes/improvements:

- Strict `InferSuccess` record replaces the 4-tuple mess (`(Expr,
TCScheme, [Diagnostic], [(Range, MarkupContent)])`) as the internal
representation; `Server.hs` will handle backwards-compat translation
- `findTypeClassWitnesses` no longer called eagerly per-AST-node at
parse time; deferred to hover-request time
- Unused-variable warnings computed via `unusedVars` and returned as
structured data rather than being conflated with hover rendering
- Better separation of concerns; no more input validation or hover index
construction
- `positionToOffset` rewritten to avoid `!!` list indexing; now uses
`scanl'` and `drop` with pattern match for safe O(line) offset
computation
- `completionQueryAt` uses `Text.takeWhileEnd` / `Text.dropEnd` instead
of splitting into lines and indexing
- Completion logic factored into composable pieces (`findInPrelude`,
`identifierCompletionItems`, `rwsCompletionItems`,
`filterModuleNameCompletionItems`) rather than one monolithic function
- `CompletionCtx` record shares computed state (type classes, prefix)
across items without recomputing per-entry
**NOTE** Server is _not_ done yet. But these changes along with the
earlier work address the major structural issues with `inferno-lsp`

All of the handlers (opening, closing, editing, etc...) are now
implemented and address the **major** structural problems we've
identified:

| Problem | Fix | Details |

|-------------------------------------|--------------------------------------------------------|----------------------------------------------------------------------------|
| Unbounded hover map | `DocStore` per-doc; replace on change, delete on
close | `updateDoc` replaces state and clears `hoverCache`, `closeDoc`
removes entry entirely |
| No debounce | `threadDelay` + `cancel` pattern | `handleDidChange`
includes debounce inside the async |
| Eager `findTypeClassWitnesses` | Deferred to hover-request time; only
for queried node | `mkPrettyTy` calls it lazily in `renderHoverContent`,
invoked only inside `lookupHover` STM transaction for the single queried
node |
| No cancellation | Per-doc `Async` handle; `cancel` on new edit |
`cancelAnalysis` on every DidChange; async handle stored via
`setAnalysis` |
| `!!` list indexing for current line | Rewrite `positionToOffset` |
Part of `Completion` module rewrite (existing) |
| O(n) hover scan | `IntervalMap.containing` for O(log n) point query |
`queryHover` uses `IntervalMap.containing` with `smallestContaining`
fold |
| 11 positional arg entrypoint | `LspConfig c` record | `LspConfig`
record with modifier pattern in `runLsp` |
| | | |

Additionally:
- **Reactor removal**: There's no more unnecessary `TChan`/reactor.
Handlers now run directly in `LspT`, per-doc asyncs handle concurrency.
The `TChan` stuff probably (?) comes from pre-v1 `haskell-lsp`. The
issue is that:
- A slow parse on doc A blocks hover responses for doc B (i.e.
everything is serialized)
  - You can't cancel stale work because it's already in the queue
  - The queue grows without bound under rapid typing (no backpressure)
  - No per-document parallelism at all
- **DidClose handler**: Exists now (old code lacked it entirely). This
cancels analysis and deletes it from from `DocStore`
- **`hoverCache` bounded by design**: Cleared on every analysis update;
keyed per-position within a single doc and evicted on close
Final implementation of the `Server` module, completing all remaining
work. No more stubs remain. Also switches many things from straight `IO`
to `InfernoLspM c` since it's 1. more idiomatic and 2. works with
`MonadUnliftIO`, which we've been using all along. Cleans up cabal file
and also bumps versions. There were some version bumps missing from
`inferno-core` from first UF and then parser rewrite, so I added those
too
Because of the `fun <idents> -> \n` injection nonsense

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors/re-writes inferno-lsp’s server implementation to address major performance/memory issues (unbounded hover growth, no debounce/cancellation, queued reactor architecture), while preserving the external LSP connection API and improving internal modularity.

Changes:

  • Introduces per-document STM state (DocStore/DocState) with cancellation + debounced analysis, and removes the global reactor queue.
  • Reworks hover and completion to be more efficient (interval-map hover index, deferred typeclass witness search, safer completion prefix extraction).
  • Reorganizes LSP logic into focused modules and updates supporting packages (virtual record fields, cabal/changelog/version bumps).

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
inferno-types/src/Inferno/Types/Syntax.hs Adds HasField instances to enable OverloadedRecordDot accessors for positional types (ImplType, Scheme, TCScheme).
inferno-lsp/src/Inferno/LSP/Server.hs New server architecture: per-document analysis lifecycle, debounce/cancel orchestration, diagnostics publishing, and config refactor.
inferno-lsp/src/Inferno/LSP/ParseInfer.hs Separates parse/infer pipeline + diagnostic generation into a language-level module; provides timeout utility.
inferno-lsp/src/Inferno/LSP/Hover.hs Adds interval-map hover indexing and markdown hover rendering with cached witness resolution.
inferno-lsp/src/Inferno/LSP/DocState.hs Introduces per-document state and a concurrent DocStore with cancellation support.
inferno-lsp/src/Inferno/LSP/Completion.hs Refactors completion prefix extraction to be total; restructures prelude filtering and item construction.
inferno-lsp/inferno-lsp.cabal Bumps version, adds new modules/deps, and adjusts warning flags.
inferno-lsp/CHANGELOG.md Documents the 0.3.0.0 rewrite.
inferno-core/src/Inferno/Module.hs Removes an unused import from the module layer.
inferno-core/src/Inferno/Infer.hs Removes HasField instances for ImplType (now centralized in inferno-types).
inferno-core/inferno-core.cabal Bumps inferno-core version.
inferno-core/CHANGELOG.md Adds entries for 0.11.7.0/0.11.8.0 (with a small typo to fix).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread inferno-lsp/src/Inferno/LSP/Server.hs
Comment thread inferno-lsp/inferno-lsp.cabal
Comment thread inferno-core/CHANGELOG.md Outdated

@LTibbetts LTibbetts left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall great work and approved!

Requests:

  • Clarify intent of error diagnostics always being at the start of the document

Potential Changes:

  • Move the version check inside of updateDoc

If you feel like it:

  • Negligible copilot comments like spelling, duplicate unused packages flag

Comment thread inferno-lsp/src/Inferno/LSP/Server.hs
Comment thread inferno-lsp/src/Inferno/LSP/Server.hs
@ngua
ngua merged commit 5f6cfbe into main Jun 3, 2026
1 check passed
@ngua
ngua deleted the rory-fix-lsp branch June 3, 2026 04:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants