Repository navigation
[inferno-lsp] Refactor LSP server to address several critical problems - #225
Merged
Merged
Conversation
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
There was a problem hiding this comment.
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.
LTibbetts
approved these changes
Jun 2, 2026
LTibbetts
left a comment
Contributor
There was a problem hiding this comment.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is essentially a complete rewrite of the
inferno-lspserver 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 theLspConfigrecord 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
lspthat 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).DidChangeinserted 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-lspserver 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 frominferno-lsp.Now we use a per-document (not global)
TVar DocStateinside a concurrentStmContainers.Map. Each document's hover state is replaced (not appended) on every successful analysis.DidClosecancels 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
DidChangecancels the previous document's in-flightAsync(which interrupts thethreadDelayimmediately since it's an interruptible operation), then spawns a new async that waitsdebounceMs(default 150ms) before running the pipeline. A version guard (whenCurrentVersion) prevents stale results from overwriting fresh diagnostics (possible race condition).Eager
findTypeClassWitnessesat parse timeThe old server called
findTypeClassWitnessesfor 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
findTypeClassWitnessesis deferred to hover-request time only and runs it for the one queried node. The result is cached in anIntMapkeyed 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
IntervalMapwith 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
completionQueryAtusedText.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 totaldropand 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 staleDidChanges and the channel could grow without bound during bursts.The reactor has been removed entirely. The
lsplibrary itself already provides concurrency and handlers execute directly inLspT. Per-document asyncs are naturally serialized by the cancel-and-replace pattern, and read-only handlers (hover, completion) access current state immediately viaSTMwith no need for queuing.11 positional arguments to the server entry point
The old
runInfernoLspServerWithtook 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 underdocumentedThere's now an
LspConfig cwith named fields and sensible defaults. Callers supply aLspConfig c -> LspConfig cmodifier to override only what they need. I keptrunInfernoLspServeras a backwards-compatible wrapper that uses default config.Make
parseAndInferWithTimeouta real utility functionPreviously,
parseAndInferWithTimeoutwas 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 twoconst ...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) andParseInfer.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 logicHover:HoverIndex(interval map), linearization, point queries, hover content rendering (mkPrettyTy,metadataDocsText). NoIOorSTMParseInfer: Parse/infer pipeline orchestration and diagnostic conversion. No hover rendering, completions, orSTM. It does not handle input validation (domain policy) orHoverIndexconstruction (both belong to the Server)Completion: Completion prefix extraction, prelude/identifier/module/keyword filtering,CompletionItemconstruction. NoIOor document stateServer: Actual LSP handlers,LspConfig/Env, debounce/cancel orchestration,afterParsecallback integration, diagnostic publishing. Delegates all other domain logic to the modules above