Repository: swyddfa/lsp-devtools (language: python)

Issue:
Missing test coverage
The test coverage of the language server is not working with the current setup if the language server is also implemented in python.
The problem seems to be, that `client.shutdown_session()` is not waiting for the async subprocess to finish gracefully.

The following workaround seems to work.


```
@pytest_lsp.fixture(  # type: ignore[misc]
    config=ClientServerConfig(
        server_command=[sys.executable, "..."],
    )
)
async def client(lsp_client: LanguageClient):  # type: ignore[no-untyped-def]
    # Setup
    await lsp_client.initialize_session(
        InitializeParams(
            capabilities=client_capabilities("visual-studio-code"),
        )
    )

    yield lsp_client

    # Teardown
    await lsp_client.shutdown_session()
    # Needed, otherwise the coverage of the server process is not collected.
    server_process = lsp_client._server
    if server_process:
        await server_process.wait()
```
Fixed server shutdown for coverage
Fixes https://github.com/swyddfa/lsp-devtools/issues/172


Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bfa8851..d6630bd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -10,7 +10,7 @@ repos:
   - id: trailing-whitespace
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.4.4
+  rev: v0.4.8
   hooks:
   - id: ruff
     args: [--fix]
diff --git a/lib/lsp-devtools/hatch.toml b/lib/lsp-devtools/hatch.toml
index ec9d4ed..84eabdc 100644
--- a/lib/lsp-devtools/hatch.toml
+++ b/lib/lsp-devtools/hatch.toml
@@ -13,4 +13,4 @@ extra-dependencies = ["pytest-asyncio"]
 
 [envs.hatch-static-analysis]
 config-path = "ruff_defaults.toml"
-dependencies = ["ruff==0.4.4"]
+dependencies = ["ruff==0.4.8"]


Test patch:
diff --git a/docs/pytest-lsp/guide/getting-started-fail-output.txt b/docs/pytest-lsp/guide/getting-started-fail-output.txt
index 3cfde65..4c06111 100644
--- a/docs/pytest-lsp/guide/getting-started-fail-output.txt
+++ b/docs/pytest-lsp/guide/getting-started-fail-output.txt
@@ -1,17 +1,18 @@
 $ pytest
-================================================ test session starts ================================================
-platform linux -- Python 3.11.3, pytest-7.2.0, pluggy-1.0.0
-rootdir: /tmp/pytest-of-alex/pytest-38/test_getting_started_fail0, configfile: tox.ini
-plugins: asyncio-0.21.0, typeguard-3.0.2, lsp-0.3.0
+============================= test session starts ==============================
+platform linux -- Python 3.11.9, pytest-7.4.4, pluggy-1.5.0
+rootdir: /tmp/pytest-of-alex/pytest-12/test_getting_started_fail0
+configfile: tox.ini
+plugins: lsp-0.4.2, asyncio-0.23.8
 asyncio: mode=Mode.AUTO
 collected 1 item
 
-test_server.py E                                                                                              [100%]
+test_server.py E                                                         [100%]
 
-====================================================== ERRORS =======================================================
-________________________________________ ERROR at setup of test_completions _________________________________________
+==================================== ERRORS ====================================
+______________________ ERROR at setup of test_completions ______________________
 
-lsp_client = <pytest_lsp.client.LanguageClient object at 0x7fa2c4168310>
+lsp_client = <pytest_lsp.client.LanguageClient object at 0x7ff8e78d7f10>
 
     @pytest_lsp.fixture(
         config=ClientServerConfig(server_command=[sys.executable, "server.py"]),
@@ -22,24 +23,45 @@ lsp_client = <pytest_lsp.client.LanguageClient object at 0x7fa2c4168310>
 >       await lsp_client.initialize_session(params)
 
 test_server.py:21:
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
-/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pytest_lsp/client.py:137: in initialize_sess
-ion
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+/var/home/alex/Projects/swyddfa/lsp-devtools/release/lib/pytest-lsp/pytest_lsp/client.py:245: in initialize_session
     response = await self.initialize_async(params)
-/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pygls/lsp/client.py:349: in initialize_async
+/var/home/alex/.local/share/hatch/env/virtual/pytest-lsp/oa_H1-lS/hatch-test.py3.11-7/lib/python3.11/site-packages/pygls/lsp/client.py:244: in initialize_async
     return await self.protocol.send_request_async("initialize", params)
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
 
-self = <pytest_lsp.protocol.LanguageClientProtocol object at 0x7fa2c417a190>, method = 'initialize'
-params = InitializeParams(capabilities=ClientCapabilities(workspace=None, text_document=None, notebook_document=None,
- window=No..., root_path=None, root_uri=None, initialization_options=None, trace=None, work_done_token=None, workspac
-e_folders=None)
+self = <pytest_lsp.protocol.LanguageClientProtocol object at 0x7ff8e78ebf90>
+method = 'initialize'
+params = InitializeParams(capabilities=ClientCapabilities(workspace=None, text_document=None, notebook_document=None, window=No..., root_path=None, root_uri=None, initialization_options=None, trace=None, work_done_token=None, workspace_folders=None)
 
     async def send_request_async(self, method, params=None):
+        """Wrap pygls' ``send_request_async`` implementation. This will
+
+        - Check the params to see if they're compatible with the client's stated
+          capabilities
+        - Check the result to see if it's compatible with the client's stated
+          capabilities
+
+        Parameters
+        ----------
+        method
+           The method name of the request to send
+
+        params
+           The associated parameters to go with the request
+
+        Returns
+        -------
+        Any
+           The result
+        """
+        check_params_against_client_capabilities(
+            self._server.capabilities, method, params
+        )
 >       result = await super().send_request_async(method, params)
-E       asyncio.exceptions.CancelledError: Server process exited with return code: 0
+E       RuntimeError: Server process 42326 exited with code: 0
 
-/var/home/alex/Projects/lsp-devtools/.env/lib64/python3.11/site-packages/pytest_lsp/protocol.py:42: CancelledError
-============================================== short test summary info ==============================================
-ERROR test_server.py::test_completions - asyncio.exceptions.CancelledError: Server process exited with return code: 0
-================================================= 1 error in 1.15s ==================================================
+/var/home/alex/Projects/swyddfa/lsp-devtools/release/lib/pytest-lsp/pytest_lsp/protocol.py:81: RuntimeError
+=========================== short test summary info ============================
+ERROR test_server.py::test_completions - RuntimeError: Server process 42326 e...
+=============================== 1 error in 1.12s ===============================
diff --git a/lib/pytest-lsp/changes/173.fix.md b/lib/pytest-lsp/changes/173.fix.md
new file mode 100644
index 0000000..68dd5b4
--- /dev/null
+++ b/lib/pytest-lsp/changes/173.fix.md
@@ -0,0 +1,1 @@
+The client now waits for the server process to gracefully exit by @OhioDschungel6
diff --git a/lib/pytest-lsp/hatch.toml b/lib/pytest-lsp/hatch.toml
index 3449ac5..6f34f4a 100644
--- a/lib/pytest-lsp/hatch.toml
+++ b/lib/pytest-lsp/hatch.toml
@@ -23,4 +23,4 @@ matrix.pytest.dependencies = [
 
 [envs.hatch-static-analysis]
 config-path = "ruff_defaults.toml"
-dependencies = ["ruff==0.4.4"]
+dependencies = ["ruff==0.4.8"]
diff --git a/lib/pytest-lsp/pytest_lsp/client.py b/lib/pytest-lsp/pytest_lsp/client.py
index 33c6dd9..213bc44 100644
--- a/lib/pytest-lsp/pytest_lsp/client.py
+++ b/lib/pytest-lsp/pytest_lsp/client.py
@@ -104,16 +104,18 @@ async def stop(self):
 
     async def server_exit(self, server: asyncio.subprocess.Process):
         """Called when the server process exits."""
-        logger.debug("Server process exited with code: %s", server.returncode)
 
         if self._stop_event.is_set():
             return
 
-        loop = asyncio.get_running_loop()
-        loop.call_soon(
-            cancel_all_tasks,
-            f"Server process exited with return code: {server.returncode}",
-        )
+        # TODO: Should the upstream base client be doing this?
+        # Cancel any pending futures.
+        reason = f"Server process {server.pid} exited with code: {server.returncode}"
+
+        for id_, fut in self.protocol._request_futures.items():
+            if not fut.done():
+                fut.set_exception(RuntimeError(reason))
+                logger.debug("Cancelled pending request '%s': %s", id_, reason)
 
     def report_server_error(
         self, error: Exception, source: Union[PyglsError, JsonRpcException]
@@ -261,7 +263,10 @@ async def shutdown_session(self) -> None:
             return
 
         await self.shutdown_async(None)
+
         self.exit(None)
+        if self._server:
+            await self._server.wait()
 
     async def wait_for_notification(self, method: str):
         """Block until a notification with the given method is received.
diff --git a/lib/pytest-lsp/tests/test_examples.py b/lib/pytest-lsp/tests/test_examples.py
index dd95c24..7ee0b17 100644
--- a/lib/pytest-lsp/tests/test_examples.py
+++ b/lib/pytest-lsp/tests/test_examples.py
@@ -1,5 +1,4 @@
 import pathlib
-import sys
 
 import pytest
 
@@ -102,12 +101,8 @@ def test_getting_started_fail(pytester: pytest.Pytester):
     results = pytester.runpytest()
     results.assert_outcomes(errors=1)
 
-    if sys.version_info < (3, 9):
-        message = "E*CancelledError"
-    else:
-        message = "E*asyncio.exceptions.CancelledError: Server process exited with return code: 0"
-
-    results.stdout.fnmatch_lines(message)
+    message = r"E\s+RuntimeError: Server process \d+ exited with code: 0"
+    results.stdout.re_match_lines(message)
 
 
 def test_generic_rpc(pytester: pytest.Pytester):
diff --git a/lib/pytest-lsp/tests/test_plugin.py b/lib/pytest-lsp/tests/test_plugin.py
index ecdd233..19a82be 100644
--- a/lib/pytest-lsp/tests/test_plugin.py
+++ b/lib/pytest-lsp/tests/test_plugin.py
@@ -129,12 +129,8 @@ async def test_capabilities(client):
 
     results.assert_outcomes(errors=1)
 
-    if sys.version_info < (3, 9):
-        message = "E*CancelledError"
-    else:
-        message = "E*asyncio.exceptions.CancelledError: Server process exited with return code: 0"
-
-    results.stdout.fnmatch_lines(message)
+    message = r"E\s+RuntimeError: Server process \d+ exited with code: 0"
+    results.stdout.re_match_lines(message)
 
 
 def test_detect_server_exit_mid_request(pytester: pytest.Pytester):
@@ -165,12 +161,8 @@ async def test_capabilities(client):
 
     results.assert_outcomes(failed=1, errors=1)
 
-    if sys.version_info < (3, 9):
-        message = "E*CancelledError"
-    else:
-        message = "E*asyncio.exceptions.CancelledError: Server process exited with return code: 0"
-
-    results.stdout.fnmatch_lines(message)
+    message = r"E\s+RuntimeError: Server process \d+ exited with code: 0"
+    results.stdout.re_match_lines(message)
     results.stdout.fnmatch_lines("E*RuntimeError: Client has been stopped.")
 
 
@@ -190,15 +182,9 @@ async def test_capabilities(client):
 
     results.assert_outcomes(errors=1)
 
-    if sys.version_info < (3, 9):
-        message = "E*CancelledError"
-    else:
-        message = [
-            "E*asyncio.exceptions.CancelledError: Server process exited with return code: 1",
-            "ZeroDivisionError: division by zero",
-        ]
-
-    results.stdout.fnmatch_lines(message)
+    message = r"E\s+RuntimeError: Server process \d+ exited with code: 1"
+    results.stdout.re_match_lines(message)
+    results.stdout.fnmatch_lines("ZeroDivisionError: division by zero")
 
 
 def test_detect_invalid_json(pytester: pytest.Pytester):