Skip to content

Language server crashes on workspace/semanticTokens/refresh when client doesn't support it #88

Description

@zhengnanli

Summary

The language server crashes shortly after opening any .m file, when used with a client that doesn't implement the server-initiated workspace/semanticTokens/refresh request (e.g. Helix).

Environment

  • MATLAB-language-server: main branch (commit 2c300ba / v1.3.12)
  • Node.js: v26.4.0
  • Client: Helix 25.07.1

Steps to reproduce

  1. Configure Helix (or any LSP client that responds to unimplemented server-to-client requests with a MethodNotFound error rather than silently ignoring them) to use matlab_ls --stdio.
  2. Open a .m file.
  3. After indexing runs (~150ms debounce), the server sends a workspace/semanticTokens/refresh request to the client.
  4. The client replies with a MethodNotFound error.
  5. The whole language server process crashes.

Actual behavior

ResponseError: Method not found: workspace/semanticTokens/refresh
    at handleResponse (out/index.js:45232:48)
    at handleMessage (out/index.js:45012:13)
    at processMessageQueue (out/index.js:45029:17)
    at Immediate.<anonymous> (out/index.js:45001:13)
    at process.processImmediate (node:internal/timers:504:21) {
  code: -32601,
  data: undefined
}

Node.js v26.4.0

The process exits entirely.

Root cause

In src/providers/semanticTokens/SemanticTokensProvider.ts, setupSemanticTokensRefresh() sends the request without handling a rejection:

refreshTimer = setTimeout(() => {
    void connection.sendRequest('workspace/semanticTokens/refresh')
}, 150)

void only discards the returned promise's value — it does not attach a rejection handler. If the client rejects the request (e.g. with MethodNotFound, since workspace/semanticTokens/refresh is optional per the LSP spec and many clients don't implement it), this becomes an unhandled promise rejection, which is fatal under Node's default --unhandled-rejections=throw behavior and kills the process.

Additionally, per the LSP spec, a server should only send this request if the client declared capabilities.workspace.semanticTokens.refreshSupport. This code (introduced in #84) sends it unconditionally, with no capability check anywhere in server.ts where setupSemanticTokensRefresh is wired up.

To summarize, there are two compounding issues:

  1. workspace/semanticTokens/refresh is optional per the LSP spec — a server should only send it if the client declared capabilities.workspace.semanticTokens.refreshSupport. This is never checked anywhere setupSemanticTokensRefresh is wired up.
  2. void discards the returned promise but does not handle rejection. Clients that don't implement the request (e.g. Helix) reply with a MethodNotFound error, which becomes an unhandled promise rejection and crashes the entire language server process.

Proposed fix

  • setupSemanticTokensRefresh takes the negotiated ClientCapabilities and only registers the indexing→refresh wiring if the client declared workspace.semanticTokens.refreshSupport === true. The call site in server.ts moves from the top-level server setup into the onInitialized handler (where capabilities is actually populated), alongside the existing workspaceIndexer.setupCallbacks(capabilities) / workspaceFolders capability checks — matching the pattern already used there.
  • Keep a defensive .catch() on the sendRequest call itself, in case a client declares support but still errors on a given call.

Diff

--- a/src/providers/semanticTokens/SemanticTokensProvider.ts
+++ b/src/providers/semanticTokens/SemanticTokensProvider.ts
@@ -1,4 +1,4 @@
-import { SemanticTokens, SemanticTokensParams, TextDocuments, Range, Connection } from 'vscode-languageserver'
+import { ClientCapabilities, SemanticTokens, SemanticTokensParams, TextDocuments, Range, Connection } from 'vscode-languageserver'
 import MatlabLifecycleManager from '../../lifecycle/MatlabLifecycleManager'
 import { TextDocument } from 'vscode-languageserver-textdocument'
 import FileInfoIndex, { MatlabFunctionScopeInfo, MatlabGlobalScopeInfo } from '../../indexing/FileInfoIndex'
@@ -121,17 +121,25 @@ export default SemanticTokensProvider
 /**
  * Wires semantic token invalidation to document indexing.
  *
  * When indexing completes, this schedules a debounced refresh request
  * so the client re-requests semantic tokens and updates highlighting.
+ * Only registered if the client declared support for the request, since
+ * it is optional per the LSP spec and not all clients implement it.
  */
 export function setupSemanticTokensRefresh (
     connection: Connection,
-    documentIndexer: DocumentIndexer
+    documentIndexer: DocumentIndexer,
+    capabilities: ClientCapabilities
 ): void {
+    if (capabilities.workspace?.semanticTokens?.refreshSupport !== true) {
+        return
+    }
+
     let refreshTimer: NodeJS.Timeout | undefined

     documentIndexer.setOnIndexed(() => {
         if (refreshTimer != null) clearTimeout(refreshTimer)

         refreshTimer = setTimeout(() => {
-            void connection.sendRequest('workspace/semanticTokens/refresh')
+            connection.sendRequest('workspace/semanticTokens/refresh').catch(() => {
+                // Defensive: ignore rejection even though refreshSupport was declared.
+            })
         }, 150)
     })
 }

--- a/src/server.ts
+++ b/src/server.ts
@@ -186,6 +186,8 @@
         workspaceIndexer.setupCallbacks(capabilities)

         if (capabilities.workspace?.workspaceFolders != null) {
             // If workspace folders are supported, try to synchronize the MATLAB path with the user's workspace.
             pathSynchronizer = new PathSynchronizer(matlabLifecycleManager, mvm)
             pathSynchronizer.initialize()
         }
+
+        setupSemanticTokensRefresh(connection, documentIndexer, capabilities)
     })
@@ -389,6 +391,5 @@
     connection.onRequest(SemanticTokensRequest.method, async (params: SemanticTokensParams) => {
         return await semanticTokensProvider.handleSemanticTokensRequest(params, documentManager)
     })
-    setupSemanticTokensRefresh(connection, documentIndexer)
 }

Testing

Verified with a scripted JSON-RPC client standing in for the LSP client:

  • Client declares no workspace.semanticTokens.refreshSupport (Helix's actual behavior): the server no longer sends workspace/semanticTokens/refresh at all after indexing.
  • Client declares refreshSupport: true: the request is still sent and, even if rejected, no longer crashes the process.

Before this change, the server crashed with an unhandled ResponseError: Method not found: workspace/semanticTokens/refresh any time a non-supporting client (like Helix) was used.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions