Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion src/java_functional_lsp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""java-functional-lsp: A Java LSP server enforcing functional programming best practices."""

__version__ = "0.7.6"
__version__ = "0.7.7"
18 changes: 16 additions & 2 deletions src/java_functional_lsp/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
16 changes: 12 additions & 4 deletions src/java_functional_lsp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -406,16 +414,16 @@ 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)
if started:
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)

Expand Down
5 changes: 1 addition & 4 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -424,15 +424,13 @@ 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:
with (
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()),
):
Expand All @@ -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."""
Expand Down
Loading