Skip to content

feat: demand-driven module prioritization for jdtls requests#47

Merged
aviadshiber merged 8 commits intomainfrom
fix/jdtls-init-timeout
Apr 11, 2026
Merged

feat: demand-driven module prioritization for jdtls requests#47
aviadshiber merged 8 commits intomainfrom
fix/jdtls-init-timeout

Conversation

@aviadshiber
Copy link
Copy Markdown
Owner

@aviadshiber aviadshiber commented Apr 11, 2026

Summary

Demand-driven jdtls module loading with adaptive waiting via ModuleRegistry.

Architecture

ModuleRegistry (new)

Tracks per-module import state: UNKNOWN → ADDED → READY

Component Purpose
dict[str, ModuleState] O(1) hot-path lookups (is_ready)
dict[str, asyncio.Event] Adaptive waiting — no fixed sleep

Request flow (_ensure_module_and_forward)

Module state Action Latency
READY Forward immediately 0ms (identity check)
UNKNOWN Add module → try → wait_until_ready → retry Adaptive (wakes on Event.set)
ADDED Try → wait_until_ready → retry Adaptive

On first successful response, mark_ready() fires Event.set() — waking all waiting coroutines instantly. All subsequent requests skip waiting entirely.

Key properties

  • No locks needed: asyncio is single-threaded; dict mutations before await are atomic
  • No fixed sleep: asyncio.Event.wait() with 30s timeout replaces the old 3s asyncio.sleep
  • Non-blocking: Event.wait() suspends without blocking the event loop
  • Deduplication: mark_added() before any await prevents duplicate didChangeWorkspaceFolders

Also includes (from earlier commits)

  • Lazy module-scoped jdtls init (first didOpen → nearest pom.xml)
  • Incremental module loading (add_module_if_new on each didOpen)
  • Background full workspace expansion
  • Dynamic jdtls capability registration
  • Bounded notification queue (deque(maxlen=200))
  • Cached find_module_root (@lru_cache)

Bug fixes

  • Fixed: ADDED modules returned null on second request without retrying
  • Fixed: notification drop window between _lazy_start_fired and _starting
  • Fixed: _start_failed not set on unexpected exceptions

Numbers

  • 361 tests, 84% coverage

🤖 Generated with Claude Code

aviadsTaboola and others added 8 commits April 10, 2026 21:38
The default 30s REQUEST_TIMEOUT was too short for jdtls to initialize
on large Maven monorepos (e.g., products with hundreds of modules).
jdtls needs to scan pom.xml files, resolve classpaths, and build its
index before responding to the initialize handshake.

Added INITIALIZE_TIMEOUT = 120s used only for the initialize request.
Normal request timeout stays at 30s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jdtls no longer starts at on_initialized. Instead:

1. on_initialized: lightweight PATH check only (check_available)
2. First didOpen: start jdtls scoped to the nearest Maven/Gradle module
   via find_module_root() — fast init (2-3s vs 30-120s for full monorepo)
3. Subsequent didOpen: add new modules incrementally via
   workspace/didChangeWorkspaceFolders (add_module_if_new)
4. Background: expand to full workspace root (expand_full_workspace)
   so cross-module references work for all files

Key design decisions:
- Non-blocking: lazy start runs as background task so on_did_open returns
  immediately with custom diagnostics (never delayed by jdtls cold-start)
- asyncio.Lock prevents double-start from rapid didOpen calls
- _start_failed flag prevents retry loops after failure
- Data-dir hash based on original monorepo root (stable across restarts)
- Notification queue: didOpen/didChange/didSave/didClose buffered during
  jdtls startup, replayed after initialization completes
- INITIALIZE_TIMEOUT removed (30s REQUEST_TIMEOUT sufficient for single module)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…, helpers

Correctness:
- Fix didOpen during startup silently dropped: now queued like didChange/save
- Use _lazy_start_fired flag to prevent TOCTOU race on task creation
- Deep copy init_params before mutation (ws['workspaceFolders'] = True)
- expand_full_workspace removes initial module folder to avoid double-indexing
- Return early in add_module_if_new when from_fs_path returns None

Performance:
- Cap notification queue at 200 entries (drop oldest on overflow)
- Cache find_module_root results with lru_cache (avoid repeated stat walks)

Quality:
- Extract _resolve_module_uri helper (DRY: was duplicated 3 times)
- Extract _forward_or_queue helper (DRY: was duplicated in 3 handlers)
- Extract _WORKSPACE_DID_CHANGE_FOLDERS constant

Tests:
- Assert flush/expand called in test_lazy_start_jdtls_success
- Add test_lazy_start_jdtls_silent_failure (ensure_started returns False)
- Convert test_queue_and_flush to async (fix deprecated get_event_loop)
- Add test_queue_caps_at_max
- Add test_expand_full_workspace_noop_when_not_available
- Assert queue cleared in test_ensure_started_no_retry_after_failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… requests

When an LSP operation (hover, goToDefinition, findReferences, completion,
documentSymbol) targets a file whose module isn't loaded in jdtls yet:

1. add_module_if_new() sends workspace/didChangeWorkspaceFolders immediately
2. Forwards the request to jdtls
3. If jdtls returns null AND the module was just added → waits 3s → retries

This makes tool usage itself drive what gets prioritized — no config needed.
Agents calling goToDefinition on a new module will see it auto-load and the
result come back after a single retry.

Changes:
- add_module_if_new returns bool (True if module was new)
- Extract _ensure_module_and_forward helper (DRY across 5 handlers)
- _MODULE_IMPORT_WAIT_SEC = 3.0 for retry delay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…provements

Correctness:
- Fix notification drop window: _forward_or_queue now uses _lazy_start_fired
  (set synchronously) instead of _starting (set inside lock) as queue gate
- Set _start_failed on any exception in ensure_started (not just start() failures)
- Document lru_cache staleness as known limitation

Performance:
- Replace list.pop(0) with collections.deque(maxlen=200) for O(1) overflow

Tests:
- Mock asyncio.sleep in retry test (was waiting 3 real seconds)
- Assert oldest surviving entry in queue cap test
- Add test for expand_full_workspace removal logic (initial module removed)
- Add cache_clear autouse fixture to TestFindModuleRoot
- Add URI scheme assertion in ensure_started_with_build_file test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace flat set + fixed 3s sleep with a proper state machine:

ModuleRegistry tracks UNKNOWN → ADDED → READY per module using:
- dict[str, ModuleState] for O(1) hot-path lookups (is_ready)
- dict[str, asyncio.Event] for adaptive waiting (no fixed sleep)

_ensure_module_and_forward now:
- READY: forward immediately (zero overhead — identity comparison)
- UNKNOWN: add_module → mark_added before await (atomic) → try request
  → if null, wait_until_ready (adaptive) → retry
- ADDED: try request → if null, wait_until_ready → retry

On first success, mark_ready() wakes all waiting coroutines instantly
via Event.set(). Subsequent requests skip waiting entirely.

Fixes bug where ADDED modules returned null on second request without
retrying (add_module_if_new returned False → no retry path).

No locks needed: asyncio is single-threaded, dict mutations before await
are atomic. asyncio.Event.wait() suspends without blocking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix is_ready: use == instead of `is` for string comparison (portable)
- Fix single-caller deadlock: always retry after wait (5s timeout), even
  on timeout the module may be ready from jdtls background import
- Clean up Events on mark_ready: pop from _ready_events after set()
- Add ModuleRegistry.clear() method for test cleanup
- Replace internal _states/_ready_events access in tests with clear()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@aviadshiber aviadshiber merged commit 79ac219 into main Apr 11, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants