Merge pull request #1728 from arimu1/fix/1724-content-modified-retry

Retry LSP ContentModified (-32801) errors instead of failing hard
This commit is contained in:
Dr. Dominik Jain authored and GitHub committed 2026-07-21 12:54:16 +02:00
commit 690904098e
5 files changed
+206 -9

No files matched your search

+5
View File
@@ -52,6 +52,11 @@ Status of the `main` branch. Changes prior to the next official version change w
- PHP/Intelephense: expose `file_filter` via `ls_specific_settings["php"]`, so that additional
extensions containing PHP sources (e.g. Drupal's `.module` / `.install` / `.inc` / `.theme`)
become visible to the symbol tools and are indexed by the language server #1710
- Fix: an LSP `ContentModified` (-32801) response was surfaced as a hard `SolidLSPException`
instead of being retried, as the spec expects for requests a client declared it will reissue.
`send_request` now retries such responses for methods declared via a server's
`retryOnContentModified` capability; rust-analyzer declares `textDocument/hover`, fixing the
flaky `test_find_symbol[rust_add_function]` on windows-latest. #1724
* JetBrains:
- Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be
@@ -413,6 +413,10 @@ class RustAnalyzer(SolidLanguageServer):
"textDocument/semanticTokens/full",
"textDocument/semanticTokens/range",
"textDocument/semanticTokens/full/delta",
# rust-analyzer's internal cancellation tracking does not cover hover,
# so hover requests it cancels while indexing come back ContentModified;
# we retry them ourselves (see LanguageServerInterface.send_request). #1724
"textDocument/hover",
],
},
"regularExpressions": {"engine": "ECMAScript", "version": "ES2020"},
+14 -1
View File
@@ -3194,4 +3194,17 @@ class SolidLanguageServer(ABC):
"""
Create the InitializeParams object to send to the language server during initialization.
"""
return self._create_initialize_params_builder().with_base_options(self._create_base_initialize_params()).build()
params = self._create_initialize_params_builder().with_base_options(self._create_base_initialize_params()).build()
self._register_content_modified_retry_methods(params)
return params
def _register_content_modified_retry_methods(self, params: InitializeParams) -> None:
"""
Reads back `general.staleRequestSupport.retryOnContentModified` from the InitializeParams
we are about to send and registers it with the underlying `LanguageServerInterface`, so
that `send_request` only retries `ContentModified` (-32801) responses for methods we have
actually told the server we will retry (see `LanguageServerInterface.send_request`).
"""
general_capabilities = cast(dict, params.get("capabilities", {})).get("general", {})
retry_methods = general_capabilities.get("staleRequestSupport", {}).get("retryOnContentModified", [])
self.server.set_content_modified_retry_methods(retry_methods)
+65 -8
View File
@@ -7,7 +7,7 @@ import subprocess
import threading
import time
from abc import ABC, abstractmethod
from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from queue import Empty, Queue
from typing import IO, Any, AnyStr, cast
@@ -18,7 +18,7 @@ from solidlsp.ls_config import Language
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_request import LanguageServerRequest
from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification
from solidlsp.lsp_protocol_handler.lsp_types import ErrorCodes
from solidlsp.lsp_protocol_handler.lsp_types import ErrorCodes, LSPErrorCodes
from solidlsp.lsp_protocol_handler.server import (
ENCODING,
LSPError,
@@ -36,6 +36,17 @@ from solidlsp.util import subprocess_util
log = logging.getLogger(__name__)
# Per the LSP spec, `ContentModified` (-32801) means the server discarded a stale, in-flight
# computation because the workspace changed underneath it, not that the request itself is
# invalid. A well-behaved client is expected to retry such requests -- but only requests it has
# declared, via `general.staleRequestSupport.retryOnContentModified` in its InitializeParams, that
# it will retry. We honor that contract: `send_request` only retries methods a given language
# server implementation has actually opted into via `set_content_modified_retry_methods` (see
# `SolidLanguageServer._create_initialize_params`), so this mechanism is a no-op for any server
# that hasn't declared such methods.
_CONTENT_MODIFIED_MAX_ATTEMPTS = 3
_CONTENT_MODIFIED_RETRY_DELAY = 0.2
class LanguageServerTerminatedException(Exception):
"""
@@ -155,12 +166,34 @@ class LanguageServerInterface(ABC):
self._response_handlers_lock = threading.Lock()
self._tasks_lock = threading.Lock()
self._content_modified_retry_methods: frozenset[str] = frozenset()
"""
The set of LSP method names for which `send_request` will retry a `ContentModified`
(-32801) response instead of raising immediately. Empty by default, i.e. no retries,
since retrying is only correct for methods this client has told the server (via
`general.staleRequestSupport.retryOnContentModified`) that it will reissue; see
`set_content_modified_retry_methods`.
"""
def set_request_timeout(self, timeout: float | None) -> None:
"""
:param timeout: the timeout, in seconds, for all requests sent to the language server.
"""
self._request_timeout = timeout
def set_content_modified_retry_methods(self, methods: Iterable[str]) -> None:
"""
Declares the LSP method names for which `send_request` should retry `ContentModified`
(-32801) responses. This should mirror whatever methods are declared in
`general.staleRequestSupport.retryOnContentModified` of the InitializeParams sent to the
server: that declaration is a promise to the server about which requests the client will
reissue on cancellation, so retrying anything outside of it would contradict what the
server was told.
:param methods: LSP method names (e.g. ``"textDocument/hover"``) eligible for retry.
"""
self._content_modified_retry_methods = frozenset(methods)
@abstractmethod
def is_running(self) -> bool:
"""
@@ -296,9 +329,9 @@ class LanguageServerInterface(ABC):
request.on_error(exception)
self._pending_requests.clear()
def send_request(self, method: str, params: dict | None = None) -> PayloadLike:
def _send_request_once(self, method: str, params: dict | None) -> Request.Result:
"""
Send request to the server, register the request id, and wait for the response
Sends a single request to the server (no retries) and waits for its response.
"""
with self._request_id_lock:
request_id = self.request_id
@@ -315,12 +348,36 @@ class LanguageServerInterface(ABC):
log.debug("Waiting for response to request %s with params:\n%s", method, params)
result = request.get_result(timeout=self._request_timeout)
log.debug("Completed: %s", request)
return result
if result.is_error():
raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error
def send_request(self, method: str, params: dict | None = None) -> PayloadLike:
"""
Send request to the server, register the request id, and wait for the response.
log.debug("Returning result:\n%s", result.payload)
return result.payload
If `method` is one of the methods registered via `set_content_modified_retry_methods`,
a response failing with the LSP `ContentModified` error (-32801) is retried a bounded
number of times with a short delay instead of being surfaced to the caller immediately;
see `_CONTENT_MODIFIED_MAX_ATTEMPTS` above. Methods that were not registered are never
retried, regardless of the error, so as to not retry requests behind the server's back.
"""
result = self._send_request_once(method, params)
if not result.is_error():
log.debug("Returning result:\n%s", result.payload)
return result.payload
if method in self._content_modified_retry_methods:
for attempt in range(2, _CONTENT_MODIFIED_MAX_ATTEMPTS + 1):
is_content_modified = isinstance(result.error, LSPError) and result.error.code == LSPErrorCodes.ContentModified
if not is_content_modified:
break
log.info("Request %s got ContentModified (-32801); retrying (%d/%d)", method, attempt, _CONTENT_MODIFIED_MAX_ATTEMPTS)
time.sleep(_CONTENT_MODIFIED_RETRY_DELAY)
result = self._send_request_once(method, params)
if not result.is_error():
log.debug("Returning result:\n%s", result.payload)
return result.payload
raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error
@abstractmethod
def _send_payload(self, payload: StringDict) -> None:
@@ -0,0 +1,118 @@
"""Unit tests: ``LanguageServerInterface.send_request`` retries LSP ``ContentModified`` errors.
Per the LSP spec, ``ContentModified`` (-32801) means the server discarded a stale, in-flight
computation because the workspace changed underneath it, not that the request itself is
invalid -- clients are expected to retry. This is what caused issue #1724 (flaky
``test_find_symbol[rust_add_function]`` on windows-latest): rust-analyzer returns
``ContentModified`` for cancelled hover requests, and Serena surfaced it as a hard error.
Retrying is only spec-compliant for methods the client actually declared, via
``general.staleRequestSupport.retryOnContentModified`` in its InitializeParams, that it will
retry -- so ``send_request`` only retries methods registered with it through
``set_content_modified_retry_methods`` (see ``SolidLanguageServer._create_initialize_params``).
No language markers: these use a local test double and run in catch-all.
"""
from __future__ import annotations
import logging
import pytest
from solidlsp import ls_process
from solidlsp.ls_config import Language
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_process import LanguageServerInterface, Request
from solidlsp.lsp_protocol_handler.lsp_types import LSPErrorCodes
from solidlsp.lsp_protocol_handler.server import LSPError
@pytest.fixture(autouse=True)
def _no_real_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
"""The retry delay is only there to be polite to the real server; don't pay for it in tests."""
monkeypatch.setattr(ls_process.time, "sleep", lambda _seconds: None)
class _ScriptedServer(LanguageServerInterface):
"""Test double that answers each request synchronously from a scripted list of results,
without a real language server process.
"""
def __init__(self, results: list[Request.Result], retry_methods: tuple[str, ...] = ("textDocument/hover",)) -> None:
super().__init__(Language.PYTHON, lambda _line: logging.INFO)
self._results = list(results)
self.sent_payload_count = 0
self.set_content_modified_retry_methods(retry_methods)
def is_running(self) -> bool:
return True
def _start(self) -> None:
pass
def _stop(self, timeout: float) -> None:
pass
def _send_payload(self, payload: dict) -> None:
self.sent_payload_count += 1
request = self._pending_requests[payload["id"]]
result = self._results.pop(0)
if result.is_error():
request.on_error(result.error)
else:
request.on_result(result.payload)
def _content_modified() -> Request.Result:
return Request.Result(error=LSPError(LSPErrorCodes.ContentModified, "content modified"))
def test_content_modified_is_retried_until_success() -> None:
server = _ScriptedServer([_content_modified(), _content_modified(), Request.Result(payload={"contents": "ok"})])
assert server.send_request("textDocument/hover") == {"contents": "ok"}
assert server.sent_payload_count == 3
def test_content_modified_gives_up_after_max_attempts() -> None:
max_attempts = ls_process._CONTENT_MODIFIED_MAX_ATTEMPTS
server = _ScriptedServer([_content_modified() for _ in range(max_attempts)])
with pytest.raises(SolidLSPException):
server.send_request("textDocument/hover")
assert server.sent_payload_count == max_attempts
def test_other_lsp_errors_are_not_retried() -> None:
server = _ScriptedServer([Request.Result(error=LSPError(LSPErrorCodes.RequestFailed, "boom"))])
with pytest.raises(SolidLSPException):
server.send_request("textDocument/hover")
assert server.sent_payload_count == 1
def test_request_cancelled_is_not_retried() -> None:
"""-32800 is client-initiated cancellation per the LSP spec, so it must not be retried here."""
server = _ScriptedServer([Request.Result(error=LSPError(LSPErrorCodes.RequestCancelled, "cancelled"))])
with pytest.raises(SolidLSPException):
server.send_request("textDocument/hover")
assert server.sent_payload_count == 1
def test_content_modified_is_not_retried_for_undeclared_method() -> None:
"""Retrying is a promise to the server about which methods will be reissued (per
``general.staleRequestSupport.retryOnContentModified``); a method that was never registered
via ``set_content_modified_retry_methods`` must not be retried, even on ContentModified.
This also covers non-idempotent requests like ``workspace/executeCommand``, which no server
declaration should ever include.
"""
server = _ScriptedServer([_content_modified()], retry_methods=("textDocument/hover",))
with pytest.raises(SolidLSPException):
server.send_request("workspace/executeCommand")
assert server.sent_payload_count == 1
def test_content_modified_retry_methods_default_to_empty() -> None:
"""A server that never calls `set_content_modified_retry_methods` must not retry anything."""
server = _ScriptedServer([_content_modified()], retry_methods=())
with pytest.raises(SolidLSPException):
server.send_request("textDocument/hover")
assert server.sent_payload_count == 1