Skip to content

Commit 23263a1

Browse files
committed
fix: bound JDTLS startup waits
1 parent e6b3852 commit 23263a1

4 files changed

Lines changed: 185 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Status of the `main` branch. Changes prior to the next official version change w
3434
- Allow language server priorities to be configured in `serena_config.yml` (for auto-detection during
3535
project creation)
3636
- Add `python_basedpyright` as an alternative Python language server
37+
- Java/JDTLS: bound required startup waits and report the current phase and latest language status
38+
on timeout instead of blocking until an outer task or CI limit is reached #1789
3739
- Nix/nixd: support custom `ls_path` launchers and external JSON settings through `config_path` #1737
3840
- Fix: `get_diagnostics_for_file` crashed with `SolidLSPException` for any Ansible file with at least
3941
one lint finding, because `ansible-language-server` doesn't implement `textDocument/documentSymbol`

docs/02-usage/050_configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,7 @@ The following settings are supported for the Java language server:
706706
| `lombok_show_generated` | `true` | Show Lombok-generated methods (`getX/setX`, `builder()`, `equals/hashCode/toString`, `withX`, fluent accessors) in `find_symbol`, `get_symbols_overview` and the symbol-edit tools. Set to `false` to restore the previous JDTLS default and hide the synthetic methods (e.g. when `@Data` classes pollute the outline with too many getters/setters). Requires JDTLS commit `b2d8952` / `vscode-java >= 1.53.0`; the bundled default already meets this. |
707707
| `jdtls_xmx` | `3G` | Maximum heap size for the JDTLS server JVM. |
708708
| `jdtls_xms` | `100m` | Initial heap size for the JDTLS server JVM. |
709+
| `startup_timeout` | `600` | Maximum seconds to wait for each required JDTLS startup signal. Applied separately to IntelliCode command registration (vscode-java mode only) and `ServiceReady`; on timeout Serena stops JDTLS and reports the current phase and latest language status. |
709710
| `intellicode_xmx` | `1G` | (vscode-java mode only) Maximum heap size for the IntelliCode embedded JVM. |
710711
| `intellicode_xms` | `100m` | (vscode-java mode only) Initial heap size for the IntelliCode embedded JVM. |
711712

src/solidlsp/language_servers/eclipse_jdtls.py

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import hashlib
77
import json
88
import logging
9+
import math
910
import os
1011
import pathlib
1112
import platform
@@ -145,6 +146,8 @@ class EclipseJDTLS(SolidLanguageServer):
145146
- jdtls_xms: Initial heap size for the JDTLS server JVM (default: "100m")
146147
- intellicode_xmx: Maximum heap size for the IntelliCode embedded JVM (default: "1G")
147148
- intellicode_xms: Initial heap size for the IntelliCode embedded JVM (default: "100m")
149+
- startup_timeout: Maximum seconds to wait for each required JDTLS startup signal
150+
(IntelliCode command registration and ServiceReady; default: 600)
148151
- lombok_show_generated: Show Lombok-generated methods (getX/setX/builder()/...) in document
149152
symbols by sending java.symbols.includeGeneratedCode=true to JDTLS (default: true).
150153
Set to false for @Data-heavy projects where the extra getters/setters are noise.
@@ -192,6 +195,7 @@ class EclipseJDTLS(SolidLanguageServer):
192195
jdtls_xms: "100m" # initial heap size for the JDTLS server JVM
193196
intellicode_xmx: "1G" # maximum heap size for the IntelliCode embedded JVM
194197
intellicode_xms: "100m" # initial heap size for the IntelliCode embedded JVM
198+
startup_timeout: 600 # maximum wait for each required startup signal
195199
lombok_show_generated: true # show Lombok-generated methods in document symbols (default true)
196200
gradle_version: "8.14.2"
197201
vscode_java_version: "1.54.0-923" # also accepts pinned legacy "1.42.0-561"
@@ -203,6 +207,9 @@ class EclipseJDTLS(SolidLanguageServer):
203207
```
204208
"""
205209

210+
STARTUP_TIMEOUT = 600.0
211+
STARTUP_SHUTDOWN_TIMEOUT = 5.0
212+
206213
@classmethod
207214
def supports_implementation_request(cls) -> bool:
208215
return True
@@ -221,6 +228,60 @@ def __init__(self, config: LanguageServerConfig, repository_root_path: str, soli
221228
self._service_ready_event = threading.Event()
222229
self._project_ready_event = threading.Event()
223230
self._intellicode_enable_command_available = threading.Event()
231+
self._startup_phase = "not_started"
232+
self._last_language_status: tuple[str | None, str | None] | None = None
233+
self._get_startup_timeout() # validate before a server process can be started
234+
235+
def _get_startup_timeout(self) -> float:
236+
"""Return the maximum seconds to wait for each required JDTLS startup signal."""
237+
configured_timeout = self._custom_settings.get("startup_timeout", self.STARTUP_TIMEOUT)
238+
try:
239+
timeout = float(configured_timeout)
240+
except (TypeError, ValueError) as exc:
241+
raise SolidLSPException("java.startup_timeout must be a positive finite number") from exc
242+
243+
if not math.isfinite(timeout) or timeout <= 0:
244+
raise SolidLSPException("java.startup_timeout must be a positive finite number")
245+
return timeout
246+
247+
def _set_startup_phase(self, phase: str) -> None:
248+
self._startup_phase = phase
249+
log.info("JDTLS startup phase: %s", phase)
250+
251+
def _handle_language_status(self, params: dict) -> None:
252+
log.info("Language status update: %s", params)
253+
status_type = params.get("type")
254+
status_message = params.get("message")
255+
self._last_language_status = (status_type, status_message)
256+
257+
if status_type == "ServiceReady" and status_message == "ServiceReady":
258+
self._service_ready_event.set()
259+
if status_type == "ProjectStatus" and status_message == "OK":
260+
self._project_ready_event.set()
261+
262+
def _describe_last_language_status(self) -> str:
263+
if self._last_language_status is None:
264+
return "none received"
265+
status_type, status_message = self._last_language_status
266+
return f"type={status_type!r}, message={status_message!r}"
267+
268+
def _wait_for_startup_signal(self, event: threading.Event, signal_name: str) -> None:
269+
timeout = self._get_startup_timeout()
270+
phase = f"waiting_for_{signal_name}"
271+
self._set_startup_phase(phase)
272+
log.info("Waiting up to %g seconds for JDTLS %s", timeout, signal_name)
273+
274+
if event.wait(timeout=timeout):
275+
self._set_startup_phase(f"{signal_name}_received")
276+
return
277+
278+
message = (
279+
f"JDTLS startup timed out after {timeout:g} seconds while waiting for {signal_name} "
280+
f"(phase={phase}, last_language_status={self._describe_last_language_status()})"
281+
)
282+
log.error(message)
283+
self.stop(shutdown_timeout=self.STARTUP_SHUTDOWN_TIMEOUT)
284+
raise SolidLSPException(message)
224285

225286
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
226287
ls_resources_dir = self.ls_resources_dir(self._solidlsp_settings)
@@ -1383,14 +1444,6 @@ def register_capability_handler(params: dict) -> None:
13831444
self._intellicode_enable_command_available.set()
13841445
return
13851446

1386-
def lang_status_handler(params: dict) -> None:
1387-
log.info("Language status update: %s", params)
1388-
if params["type"] == "ServiceReady" and params["message"] == "ServiceReady":
1389-
self._service_ready_event.set()
1390-
if params["type"] == "ProjectStatus":
1391-
if params["message"] == "OK":
1392-
self._project_ready_event.set()
1393-
13941447
def execute_client_command_handler(params: dict) -> list:
13951448
assert params["command"] == "_java.reloadBundles.command"
13961449
assert params["arguments"] == []
@@ -1403,18 +1456,18 @@ def do_nothing(params: dict) -> None:
14031456
return
14041457

14051458
self.server.on_request("client/registerCapability", register_capability_handler)
1406-
self.server.on_notification("language/status", lang_status_handler)
1459+
self.server.on_notification("language/status", self._handle_language_status)
14071460
self.server.on_notification("window/logMessage", window_log_message)
14081461
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
14091462
self.server.on_notification("$/progress", do_nothing)
14101463
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
14111464
self.server.on_notification("language/actionableNotification", do_nothing)
14121465

1413-
log.info("Starting EclipseJDTLS server process")
1466+
self._set_startup_phase("starting_process")
14141467
self.server.start()
14151468
initialize_params = self._create_initialize_params()
14161469

1417-
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
1470+
self._set_startup_phase("waiting_for_initialize_response")
14181471
init_response = self.server.send.initialize(initialize_params)
14191472
assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore
14201473
assert "completionProvider" not in init_response["capabilities"]
@@ -1428,7 +1481,10 @@ def do_nothing(params: dict) -> None:
14281481
# IntelliCode bundle is shipped. In upstream-jdtls mode it's absent and the
14291482
# 'java.intellicode.enable' command will never be registered, so we skip the wait/call.
14301483
if self.runtime_dependency_paths.intellicode_jar_path is not None:
1431-
self._intellicode_enable_command_available.wait()
1484+
self._wait_for_startup_signal(
1485+
self._intellicode_enable_command_available,
1486+
"intellicode_command_registration",
1487+
)
14321488

14331489
java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path
14341490
assert java_intellisense_members_path is not None
@@ -1440,13 +1496,12 @@ def do_nothing(params: dict) -> None:
14401496
}
14411497
)
14421498
assert intellicode_enable_result
1499+
self._set_startup_phase("intellicode_enabled")
14431500

1444-
if not self._service_ready_event.is_set():
1445-
log.info("Waiting for service to be ready ...")
1446-
self._service_ready_event.wait()
1447-
log.info("Service is ready")
1501+
self._wait_for_startup_signal(self._service_ready_event, "service_ready")
14481502

14491503
if not self._project_ready_event.is_set():
1504+
self._set_startup_phase("waiting_for_project_status")
14501505
log.info("Waiting for project to be ready ...")
14511506
project_ready_timeout = 20 # Hotfix: Using timeout until we figure out why sometimes we don't get the project ready event
14521507
if self._project_ready_event.wait(timeout=project_ready_timeout):
@@ -1456,7 +1511,7 @@ def do_nothing(params: dict) -> None:
14561511
else:
14571512
log.info("Project is ready")
14581513

1459-
log.info("Startup complete")
1514+
self._set_startup_phase("complete")
14601515

14611516
@override
14621517
def _request_hover(self, file_buffer: LSPFileBuffer, line: int, column: int) -> ls_types.Hover | None:
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Unit tests for bounded Eclipse JDTLS startup waits."""
2+
3+
import threading
4+
from typing import Any, cast
5+
6+
import pytest
7+
8+
from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS
9+
from solidlsp.ls_exceptions import SolidLSPException
10+
from solidlsp.settings import SolidLSPSettings
11+
12+
13+
class _FakeProtocolServer:
14+
def __init__(self, stop_error: Exception | None = None) -> None:
15+
self.stop_calls: list[float] = []
16+
self.stop_error = stop_error
17+
18+
def stop(self, timeout: float = 5.0) -> None:
19+
self.stop_calls.append(timeout)
20+
if self.stop_error is not None:
21+
raise self.stop_error
22+
23+
24+
def _bare_jdtls(
25+
custom_settings: dict | None = None,
26+
*,
27+
stop_error: Exception | None = None,
28+
) -> tuple[EclipseJDTLS, _FakeProtocolServer]:
29+
"""Build only the state touched by the startup timeout helpers."""
30+
server = object.__new__(EclipseJDTLS)
31+
server._custom_settings = SolidLSPSettings.CustomLSSettings(custom_settings)
32+
server._service_ready_event = threading.Event()
33+
server._project_ready_event = threading.Event()
34+
server._intellicode_enable_command_available = threading.Event()
35+
server._startup_phase = "not_started"
36+
server._last_language_status = None
37+
protocol_server = _FakeProtocolServer(stop_error)
38+
server.server = cast(Any, protocol_server)
39+
server.server_started = True
40+
return server, protocol_server
41+
42+
43+
def test_startup_timeout_default_and_override() -> None:
44+
server, _ = _bare_jdtls()
45+
assert server._get_startup_timeout() == 600.0
46+
47+
server, _ = _bare_jdtls({"startup_timeout": 12.5})
48+
assert server._get_startup_timeout() == 12.5
49+
50+
51+
@pytest.mark.parametrize("configured_timeout", [0, -1, "invalid", float("inf"), float("nan")])
52+
def test_startup_timeout_must_be_positive_and_finite(configured_timeout: object) -> None:
53+
server, _ = _bare_jdtls({"startup_timeout": configured_timeout})
54+
55+
with pytest.raises(SolidLSPException, match="positive finite number"):
56+
server._get_startup_timeout()
57+
58+
59+
def test_language_status_handler_tracks_latest_status_and_sets_events() -> None:
60+
server, _ = _bare_jdtls()
61+
62+
server._handle_language_status({"type": "ProjectStatus", "message": "OK"})
63+
assert server._project_ready_event.is_set()
64+
assert not server._service_ready_event.is_set()
65+
assert server._describe_last_language_status() == "type='ProjectStatus', message='OK'"
66+
67+
server._handle_language_status({"type": "ServiceReady", "message": "ServiceReady"})
68+
assert server._service_ready_event.is_set()
69+
assert server._describe_last_language_status() == "type='ServiceReady', message='ServiceReady'"
70+
71+
72+
def test_received_startup_signal_does_not_stop_server() -> None:
73+
server, protocol_server = _bare_jdtls({"startup_timeout": 1})
74+
event = threading.Event()
75+
event.set()
76+
77+
server._wait_for_startup_signal(event, "service_ready")
78+
79+
assert server._startup_phase == "service_ready_received"
80+
assert protocol_server.stop_calls == []
81+
assert server.server_started
82+
83+
84+
def test_startup_timeout_reports_phase_and_last_status_then_stops_server() -> None:
85+
server, protocol_server = _bare_jdtls({"startup_timeout": 0.001})
86+
server._handle_language_status({"type": "ProjectStatus", "message": "Starting"})
87+
88+
with pytest.raises(SolidLSPException) as exc_info:
89+
server._wait_for_startup_signal(threading.Event(), "service_ready")
90+
91+
message = str(exc_info.value)
92+
assert "after 0.001 seconds" in message
93+
assert "waiting for service_ready" in message
94+
assert "phase=waiting_for_service_ready" in message
95+
assert "last_language_status=type='ProjectStatus', message='Starting'" in message
96+
assert protocol_server.stop_calls == [5.0]
97+
assert not server.server_started
98+
99+
100+
def test_shutdown_error_does_not_hide_startup_timeout() -> None:
101+
server, protocol_server = _bare_jdtls(
102+
{"startup_timeout": 0.001},
103+
stop_error=RuntimeError("shutdown failed"),
104+
)
105+
106+
with pytest.raises(SolidLSPException, match="waiting for intellicode_command_registration"):
107+
server._wait_for_startup_signal(threading.Event(), "intellicode_command_registration")
108+
109+
assert protocol_server.stop_calls == [5.0]
110+
assert not server.server_started

0 commit comments

Comments
 (0)