From bb3a90215c941eda57a3734840dc7f5d171dcc66 Mon Sep 17 00:00:00 2001 From: Aviad Shiber Date: Sat, 11 Apr 2026 22:33:52 +0300 Subject: [PATCH] fix: remove eager workspace expansion that negated module-scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _lazy_start_jdtls function was calling _expand_workspace_background() immediately after jdtls initialized, which added the full monorepo root and removed the initial module — undoing the module-scoping optimization from #46 and causing 120s initialize timeouts on large workspaces. Changes: - Remove eager expansion; modules load on-demand via add_module_if_new() - Gate READY signal on publishDiagnostics (reliable indexing-complete signal) instead of first non-None response - Add _start_failed retry with 5-minute cooldown so transient failures don't permanently disable jdtls for the session Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- src/java_functional_lsp/__init__.py | 2 +- src/java_functional_lsp/proxy.py | 18 ++++++++++++++++-- src/java_functional_lsp/server.py | 16 ++++++++++++---- tests/test_server.py | 5 +---- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 28535e0..c227c35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "java-functional-lsp" -version = "0.7.6" +version = "0.7.7" description = "Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions" readme = "README.md" license = { text = "MIT" } diff --git a/src/java_functional_lsp/__init__.py b/src/java_functional_lsp/__init__.py index 5de1e74..a016239 100644 --- a/src/java_functional_lsp/__init__.py +++ b/src/java_functional_lsp/__init__.py @@ -1,3 +1,3 @@ """java-functional-lsp: A Java LSP server enforcing functional programming best practices.""" -__version__ = "0.7.6" +__version__ = "0.7.7" diff --git a/src/java_functional_lsp/proxy.py b/src/java_functional_lsp/proxy.py index 4005a01..828e549 100644 --- a/src/java_functional_lsp/proxy.py +++ b/src/java_functional_lsp/proxy.py @@ -12,6 +12,7 @@ import re import shutil import subprocess +import time from collections import deque from collections.abc import Callable, Mapping from functools import lru_cache @@ -22,6 +23,7 @@ REQUEST_TIMEOUT = 30.0 # seconds — per-request timeout for normal operations _INITIALIZE_TIMEOUT = 120.0 # seconds — module-scoped init can still be slow (Maven classpath resolution) +_START_RETRY_COOLDOWN = 300.0 # seconds — retry jdtls startup after transient failure DEFAULT_JVM_MAX_HEAP = "4g" _STDERR_LINE_MAX = 1000 @@ -448,6 +450,7 @@ def __init__(self, on_diagnostics: Callable[[str, list[Any]], None] | None = Non self._start_lock = asyncio.Lock() self._starting = False self._start_failed = False + self._start_failed_at: float | None = None self._jdtls_on_path = False self._lazy_start_fired = False self._queued_notifications: deque[tuple[str, Any]] = deque(maxlen=_MAX_QUEUED_NOTIFICATIONS) @@ -566,12 +569,21 @@ async def ensure_started(self, init_params: dict[str, Any], file_uri: str) -> bo """Start jdtls lazily, scoped to the module containing *file_uri*. Thread-safe: uses asyncio.Lock to prevent double-start from rapid - didOpen calls. Sets ``_start_failed`` on failure to prevent retries. + didOpen calls. Sets ``_start_failed`` on failure to prevent retries, + but allows retry after a cooldown period (5 minutes) so transient + failures (Maven Central timeout, JVM OOM) don't permanently disable jdtls. """ if self._available: return True - if self._start_failed or not self._jdtls_on_path: + if not self._jdtls_on_path: return False + if self._start_failed: + if self._start_failed_at and (time.monotonic() - self._start_failed_at > _START_RETRY_COOLDOWN): + self._start_failed = False + self._start_failed_at = None + logger.info("jdtls: retrying after previous failure (cooldown elapsed)") + else: + return False async with self._start_lock: if self._available: @@ -583,10 +595,12 @@ async def ensure_started(self, init_params: dict[str, Any], file_uri: str) -> bo started = await self.start(init_params, module_root_uri=module_uri) if not started: self._start_failed = True + self._start_failed_at = time.monotonic() self._queued_notifications.clear() return started except Exception: self._start_failed = True + self._start_failed_at = time.monotonic() self._queued_notifications.clear() raise finally: diff --git a/src/java_functional_lsp/server.py b/src/java_functional_lsp/server.py index bc35ea5..9215fa8 100644 --- a/src/java_functional_lsp/server.py +++ b/src/java_functional_lsp/server.py @@ -66,9 +66,17 @@ def __init__(self) -> None: self._proxy = JdtlsProxy(on_diagnostics=self._on_jdtls_diagnostics) def _on_jdtls_diagnostics(self, uri: str, diagnostics: list[Any]) -> None: - """Called when jdtls publishes diagnostics — merge with custom and re-publish.""" + """Called when jdtls publishes diagnostics — merge with custom and re-publish. + + Also marks the file's module as READY, since receiving diagnostics from + jdtls is a reliable signal that the module has been indexed (more reliable + than a first non-None response which may be semantically empty). + """ if not uri.endswith(".java"): return + module_uri = _resolve_module_uri(uri) + if module_uri: + self._proxy.modules.mark_ready(module_uri) try: _analyze_and_publish(uri) except Exception as e: @@ -406,8 +414,9 @@ async def _lazy_start_jdtls(file_uri: str) -> None: """Background task: start jdtls scoped to the module containing *file_uri*. Runs in the background so ``on_did_open`` returns immediately with custom - diagnostics. After jdtls initializes, registers capabilities, flushes - queued notifications, and schedules workspace expansion. + diagnostics. After jdtls initializes, registers capabilities and flushes + queued notifications. Workspace expansion is NOT done eagerly — modules + are loaded on-demand via ``add_module_if_new()`` as files are opened. """ try: started = await server._proxy.ensure_started(server._init_params, file_uri) @@ -415,7 +424,6 @@ async def _lazy_start_jdtls(file_uri: str) -> None: logger.info("jdtls proxy active — full Java language support enabled") await _register_jdtls_capabilities() await server._proxy.flush_queued_notifications() - await _expand_workspace_background() except Exception: logger.warning("jdtls lazy start failed", exc_info=True) diff --git a/tests/test_server.py b/tests/test_server.py index 4876612..c2f8362 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -415,7 +415,7 @@ async def test_register_jdtls_capabilities_idempotent(self) -> None: mock_reg.assert_not_called() async def test_lazy_start_jdtls_success(self, caplog: Any) -> None: - """_lazy_start_jdtls logs success, flushes queue, and expands workspace.""" + """_lazy_start_jdtls logs success and flushes queue (no eager expansion).""" import logging from unittest.mock import AsyncMock, MagicMock, patch @@ -424,7 +424,6 @@ async def test_lazy_start_jdtls_success(self, caplog: Any) -> None: from java_functional_lsp.server import server as srv mock_flush = AsyncMock() - mock_expand = AsyncMock() old_flag = srv_mod._jdtls_capabilities_registered srv_mod._jdtls_capabilities_registered = False try: @@ -432,7 +431,6 @@ async def test_lazy_start_jdtls_success(self, caplog: Any) -> None: caplog.at_level(logging.INFO, logger="java_functional_lsp.server"), patch.object(srv._proxy, "ensure_started", AsyncMock(return_value=True)), patch.object(srv._proxy, "flush_queued_notifications", mock_flush), - patch.object(srv._proxy, "expand_full_workspace", mock_expand), patch.object(srv, "feature", MagicMock(return_value=lambda fn: fn)), patch.object(srv, "client_register_capability_async", AsyncMock()), ): @@ -441,7 +439,6 @@ async def test_lazy_start_jdtls_success(self, caplog: Any) -> None: srv_mod._jdtls_capabilities_registered = old_flag assert any("jdtls proxy active" in r.getMessage() for r in caplog.records) mock_flush.assert_called_once() - mock_expand.assert_called_once() async def test_lazy_start_jdtls_failure_logged(self, caplog: Any) -> None: """_lazy_start_jdtls logs warning on exception."""