From cf3676986ce3ee9247cd9b28cf24ba656806f784 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 28 Oct 2026 22:44:15 -0700 Subject: [PATCH 1/3] perf(lsp): stop copying open documents and draining events on the main actor The engine copied the whole client state for every request, document change, and server message while holding the session locks. That state holds the full text of every open document, so a jdtls project import, which publishes thousands of messages, kept completion and navigation waiting behind the copies. Open document text is now shared (Arc, still a plain JSON string), and each server message is parsed once, outside the state lock, instead of up to three times. macOS drained Core's event queue on the main actor every 10 ms while requests were pending, waiting on the same session lock. The drain now runs off the main actor; a send waits for a drain already in flight, so a drain still never returns a reply before its operation is registered. Co-Authored-By: Claude Opus 5.5 --- .../Runtime/LanguageServerSession.swift | 27 ++++++- .../MacBuildProviderSessionsTests.swift | 52 ++++++++++++- rust/lithe-core/Cargo.toml | 2 +- rust/lithe-core/src/lsp/interface/client.rs | 21 +++--- rust/lithe-core/src/lsp/interface/engine.rs | 74 +++++++++---------- rust/lithe-core/src/lsp/interface/types.rs | 10 ++- rust/lithe-core/src/lsp/tests.rs | 60 +++++++++++++-- 7 files changed, 189 insertions(+), 57 deletions(-) diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index d1bcb6170e..cc68ae2d59 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -80,6 +80,10 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// While one is in flight the poll loop holds the events, so a reply /// never arrives before its operation is registered. private var sendsInFlight = 0 + /// The event drain running off the main actor, if any. A send waits for it + /// before writing, so a drain never returns the reply to a request whose + /// operation is not registered yet. + private var drainInFlight: Task<[LanguageServerRuntimeEvent], Never>? private var stopWatchdog: SendWatchdog? /// Immediate setting updates are serialized per session so an older /// detached write cannot overtake a newer edit or cross a restart. @@ -943,6 +947,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { > ) async -> Result { sendsInFlight += 1 + // A drain that started before this send must finish before the request + // is written; its events then cannot include this request's reply. + if let draining = drainInFlight { _ = await draining.value } let core = core let sending = Task.detached(priority: .userInitiated) { request(core) } let watchdog = SendWatchdog() @@ -1182,6 +1189,22 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { // MARK: - Event delivery + /// Drains Core's queued events off the main actor. The drain encodes and + /// decodes JSON and waits on the session lock that Core's reader holds while + /// it applies each server message, which during a project import is most of + /// the time; on the main actor that stalled typing and scrolling. + private func drainEvents(sessionID: String) async -> [LanguageServerRuntimeEvent] { + let core = core + let draining = Task.detached(priority: .userInitiated) { + core.pollLanguageServerEvents(sessionID: sessionID) + } + drainInFlight = draining + let events = await draining.value + // A cancelled poll loop may finish after its replacement started a drain. + if drainInFlight == draining { drainInFlight = nil } + return events + } + private func startPolling() { pollTask?.cancel() // The task intentionally retains the session: it is what releases the @@ -1192,7 +1215,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { guard let sessionID else { return } // Core keeps the events until the requests being written are // registered; see `sendsInFlight`. - let events = sendsInFlight > 0 ? [] : core.pollLanguageServerEvents(sessionID: sessionID) + let events = sendsInFlight > 0 ? [] : await drainEvents(sessionID: sessionID) + // The session may have been released or replaced meanwhile. + guard !Task.isCancelled, self.sessionID == sessionID else { return } var reachedTerminalState = false for event in events where handle(event) { reachedTerminalState = true diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index b5948a1dce..47f4abb29a 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -486,6 +486,32 @@ struct MacBuildProviderSessionsTests { #expect(fixture.endedBackends.calls == [.init(token: token, immediately: true)]) } + /// Core's event drain waits on the session lock while the server's reader + /// applies a message, so it runs off the main actor. A request sent while a + /// drain is out waits for it, so that drain cannot return the request's + /// reply before the operation is registered. + @Test + func aHeldEventDrainNeitherBlocksTheMainActorNorOvertakesARequest() async throws { + let fixture = try ProviderFixture() + defer { + fixture.core.releaseHeldPoll() + fixture.remove() + } + _ = try await fixture.sessions.pluginFunctions(of: fixture.target) + fixture.core.holdNextPoll() + // This test runs on the main actor: it resumes here only because the + // held drain runs elsewhere. + try #require(await fixture.core.pollHeld.waitUntilOpen()) + + // The call runs on the main actor up to its wait for the drain; the + // release is queued behind it. + let call = Task { @MainActor in try await fixture.invoke("workspace.build") } + let release = Task { @MainActor in fixture.core.releaseHeldPoll() } + await release.value + #expect(try await call.value == .object(["built": .bool(true)])) + #expect(fixture.core.requestsWhilePollHeld == 0) + } + /// Stopping a provider whose write is blocked does not wait on it: Core's /// stop runs off the main actor and the backend is ended after the grace. @Test @@ -720,6 +746,14 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked /// a write to a backend that stopped reading would, until `releaseStuckSend`. let stuckSendStarted = TestGate() private let stuckSendReleased = TestGate() + /// Opened when a poll armed by `holdNextPoll()` is blocked; it stays + /// blocked, as Core's drain does behind a busy session lock, until + /// `releaseHeldPoll()`. + let pollHeld = TestGate() + private let heldPollReleased = TestGate() + private var _holdNextPoll = false + private var _pollIsHeld = false + private var _requestsWhilePollHeld = 0 private let initialState: String private let readyAfterStop: Bool private let backgroundStart: Bool @@ -855,6 +889,11 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked /// Lets the blocked send fail the way a write to a killed process does. func releaseStuckSend() { stuckSendReleased.open() } + func holdNextPoll() { lock.withLock { _holdNextPoll = true } } + func releaseHeldPoll() { heldPollReleased.open() } + /// Function requests that reached Core while a held poll was out. + var requestsWhilePollHeld: Int { lock.withLock { _requestsWhilePollHeld } } + /// `workspace.build` reports progress then succeeds, `snapshot.read` /// fails with a plugin code, `workspace.wait` runs until cancelled, /// `workspace.stuck` blocks like a write to a backend that stopped @@ -881,6 +920,7 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked )) } let operation = lock.withLock { + if _pollIsHeld { _requestsWhilePollHeld += 1 } _functionRequests.append(FunctionCall(pluginID: pluginID, functionID: functionID)) let operationID = "\(sessionID)-fn-\(_functionRequests.count)" switch functionID { @@ -957,7 +997,17 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked } func pollLanguageServerEvents(sessionID: String) -> [LanguageServerRuntimeEvent] { - lock.withLock { events.removeValue(forKey: sessionID) ?? [] } + let hold = lock.withLock { + defer { _holdNextPoll = false } + _pollIsHeld = _holdNextPoll + return _holdNextPoll + } + if hold { + pollHeld.open() + _ = heldPollReleased.waitSynchronously(timeout: 5) + lock.withLock { _pollIsHeld = false } + } + return lock.withLock { events.removeValue(forKey: sessionID) ?? [] } } func syncLanguageServerDocument( diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 2ce17db427..d8d7977b35 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -22,7 +22,7 @@ regex = "1.11" quick-xml = "0.37" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } rsa = { version = "0.9", features = ["pem"] } -serde = { version = "1.0", features = ["derive"] } +serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" sha1 = "0.10" sha2 = "0.10" diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 131bdb8c3e..3e118affc6 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -151,7 +151,7 @@ pub fn client_open_document( uri: request.uri.clone(), language_id: request.language_id, version: 1, - text: request.text, + text: request.text.into(), }; let message = json_rpc_notification( "textDocument/didOpen", @@ -231,7 +231,7 @@ pub fn client_change_document( }; let mut document = document; document.version += 1; - document.text = next_text; + document.text = next_text.into(); let message = json_rpc_notification( "textDocument/didChange", json!({ @@ -346,19 +346,22 @@ fn client_feature_request_with_document_ownership( pub fn client_apply_server_message( request: ClientApplyServerMessageRequest, ) -> Result { - client_apply_server_message_for_plugin(request, false) + let message: Value = serde_json::from_str(&request.message).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP server JSON message") + .with_details(error.to_string()) + })?; + client_apply_server_message_for_plugin(request.state, &message, false) } /// Applies function protocol messages only for sessions with a runtime-owned plugin identity. +/// +/// Takes the message already parsed: the engine parses each server message once, +/// outside the session state lock, and reduces it while holding that lock. pub(super) fn client_apply_server_message_for_plugin( - request: ClientApplyServerMessageRequest, + mut state: LspClientState, + message: &Value, plugin_owned: bool, ) -> Result { - let mut state = request.state; - let message: Value = serde_json::from_str(&request.message).map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP server JSON message") - .with_details(error.to_string()) - })?; let mut responses = Vec::new(); let mut events = Vec::new(); diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 5d4c79a383..1989c25f27 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -8,11 +8,10 @@ use super::process::{LspProcessHandle, LspProcessLauncher, LspProcessSpec, Syste use super::{ client_change_document, client_close_document, client_feature_request_canonical, client_open_document, client_provider_document_feature_request_canonical, client_shutdown, - frame_message, parse_server_messages, ClientApplyServerMessageRequest, - ClientChangeDocumentRequest, ClientCloseDocumentRequest, ClientFeatureRequest, - ClientInitializeRequest, ClientOpenDocumentRequest, ClientShutdownRequest, FrameMessageRequest, - LspClientDiagnostic, LspClientDocument, LspClientState, LspDocumentContentChange, LspPosition, - LspRange, ParseServerMessagesRequest, + frame_message, parse_server_messages, ClientChangeDocumentRequest, ClientCloseDocumentRequest, + ClientFeatureRequest, ClientInitializeRequest, ClientOpenDocumentRequest, + ClientShutdownRequest, FrameMessageRequest, LspClientDiagnostic, LspClientDocument, + LspClientState, LspDocumentContentChange, LspPosition, LspRange, ParseServerMessagesRequest, }; use crate::lsp::languages::java_entrypoints::{ java_entrypoints_command, normalize_java_entrypoints, @@ -1883,7 +1882,7 @@ impl RuntimeSession { // Hosts commonly publish both a ranged edit and the resulting // full text. Repeating that full text must not create another // JDTLS reconciliation cycle or advance the semantic version. - if request.content_changes.is_empty() && document.text == request.text { + if request.content_changes.is_empty() && *document.text == *request.text { return Ok(SyncDocumentResponse { document_version: document.version.max(1), changed: false, @@ -1941,7 +1940,7 @@ impl RuntimeSession { uri, language_id: request.language_id, version: 0, - text: request.text, + text: request.text.into(), }, ); // Version zero remains an internal "not opened on the wire" @@ -3090,24 +3089,28 @@ impl RuntimeSession { ); return Ok(()); } - let message = match &self.workspace_host { - Some(host) => super::remote_process::map_message( - host, - &message, - false, - pending_before - .as_ref() - .map(|pending| pending.method.as_str()), - )?, - None => message, + // Only a remote workspace rewrites the message; a local one keeps the + // value parsed above instead of parsing the same text again. + let value = match &self.workspace_host { + Some(host) => { + let mapped = super::remote_process::map_message( + host, + &message, + false, + pending_before + .as_ref() + .map(|pending| pending.method.as_str()), + )?; + serde_json::from_str(&mapped).map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid mapped LSP server JSON message.", + ) + .with_details(error.to_string()) + })? + } + None => value, }; - let value: Value = serde_json::from_str(&message).map_err(|error| { - CoreError::new( - ErrorCode::ParseFailed, - "Invalid mapped LSP server JSON message.", - ) - .with_details(error.to_string()) - })?; let method = value.get("method").and_then(Value::as_str); let readiness = readiness_signal(&self.provider_id, method, value.get("params")); let java_import_progress = import_progress(&self.provider_id, method, value.get("params")); @@ -3123,22 +3126,19 @@ impl RuntimeSession { let reduced = { let mut state = self.lock_state()?; - let message = if method == Some("textDocument/publishDiagnostics") { + let redirected = if method == Some("textDocument/publishDiagnostics") { let state = &mut *state; diagnostics_addressed_to_open_document( &state.client.open_documents, &mut state.linked_document_paths, &value, ) - .unwrap_or(message) } else { - message + None }; client_apply_server_message_for_plugin( - ClientApplyServerMessageRequest { - state: state.client.clone(), - message, - }, + state.client.clone(), + redirected.as_ref().unwrap_or(&value), self.plugin_id.is_some(), )? }; @@ -3373,7 +3373,7 @@ impl RuntimeSession { .document_uri .as_deref() .and_then(|uri| state.client.open_documents.get(uri)) - .map(|document| document.text.as_str()) + .map(|document| document.text.as_ref()) .unwrap_or_default(); let batch = JavaNavigationMarkerBatch::new( &raw_lenses, @@ -5175,7 +5175,7 @@ fn diagnostics_addressed_to_open_document( open_documents: &BTreeMap, linked: &mut LinkedDocumentPaths, message: &Value, -) -> Option { +) -> Option { let published = message.get("params")?.get("uri")?.as_str()?; if open_documents.contains_key(published) { return None; @@ -5192,7 +5192,7 @@ fn diagnostics_addressed_to_open_document( .map(|(uri, _)| uri.clone())?; let mut message = message.clone(); message["params"]["uri"] = Value::String(document_uri); - Some(message.to_string()) + Some(message) } /// The workspace URI that scopes Java entry-point discovery. @@ -6449,7 +6449,7 @@ fn flush_queued_documents_locked(state: &mut SessionState) -> Result state: state.client.clone(), uri: document.uri, language_id: document.language_id, - text: document.text, + text: document.text.to_string(), })?; state.client = response.state; messages.extend(response.messages); @@ -8025,7 +8025,7 @@ mod tests { assert!(!snapshot.open_documents.contains_key(first_uri)); assert_eq!(snapshot.open_documents[second_uri].version, 2); assert_eq!( - snapshot.open_documents[second_uri].text, + &*snapshot.open_documents[second_uri].text, "class Second { int value; }" ); } @@ -9425,7 +9425,7 @@ mod tests { uri: uri.to_string(), language_id: "go".to_string(), version: 1, - text: String::new(), + text: "".into(), }, ) } diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index fa40091754..e49be8d2df 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "camelCase")] @@ -186,7 +187,10 @@ pub struct LspClientDocument { pub uri: String, pub language_id: String, pub version: i64, - pub text: String, + /// Shared so that copying the client state, which every request, document + /// change, and server message does while holding the session locks, does + /// not copy the text of every open document. Serializes as a plain string. + pub text: Arc, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -298,9 +302,11 @@ pub struct ClientFeatureRequest { pub command: Option, } +#[cfg(test)] #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -/// Server JSON-RPC message to reduce into the current client state. +/// Server JSON-RPC message to reduce into the current client state. Only the +/// stateless reducer tests use it; the engine passes the parsed message. pub struct ClientApplyServerMessageRequest { #[serde(default)] pub state: LspClientState, diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index ef5eb42637..7ac3d4b7fa 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -709,7 +709,8 @@ fn client_change_document_emits_incremental_ranges_when_the_server_supports_them .open_documents .get("file:///tmp/project/main.rs") .unwrap() - .text, + .text + .as_ref(), "fn main(launch) {}\n" ); let did_change: Value = serde_json::from_str(&changed.messages[0]).unwrap(); @@ -776,7 +777,7 @@ fn client_change_document_replays_multi_change_batches_end_to_start() { .open_documents .get("file:///tmp/project/multi.rs") .unwrap(); - assert_eq!(document.text, "aXbc\nd"); + assert_eq!(document.text.as_ref(), "aXbc\nd"); let did_change: Value = serde_json::from_str(&changed.messages[0]).unwrap(); let content_changes = did_change["params"]["contentChanges"].as_array().unwrap(); @@ -819,7 +820,7 @@ fn client_change_document_replays_multi_change_batches_end_to_start() { ], ) .unwrap(); - assert_eq!(replayed, document.text); + assert_eq!(replayed, document.text.as_ref()); // Left-to-right sequential apply of the original Monaco order diverges, // which is exactly the bug the end-to-start wire order prevents. @@ -855,7 +856,7 @@ fn client_change_document_replays_multi_change_batches_end_to_start() { ], ) .unwrap(); - assert_ne!(diverged, document.text); + assert_ne!(diverged, document.text.as_ref()); } #[test] @@ -910,7 +911,8 @@ fn client_change_document_falls_back_to_full_sync_for_overlapping_ranges() { .open_documents .get("file:///tmp/project/overlap.rs") .unwrap() - .text, + .text + .as_ref(), "Z" ); let did_change: Value = serde_json::from_str(&changed.messages[0]).unwrap(); @@ -1288,6 +1290,52 @@ fn client_state_keeps_legacy_diagnostics_shape_compatible() { assert!(serialized.get("diagnosticVersions").is_none()); } +// The engine copies the client state for every request, document change, and +// server message while holding the session locks. Servers publish diagnostics +// for every file during a project import, so copying the open documents' text +// each time stalled completion and navigation behind the import. +#[test] +fn client_core_reduction_shares_open_document_text() { + let uri = "file:///tmp/project/Main.java"; + let mut state = LspClientState { + initialized: true, + ..LspClientState::default() + }; + state.open_documents.insert( + uri.to_string(), + LspClientDocument { + uri: uri.to_string(), + language_id: "java".to_string(), + version: 1, + text: "class Main {}\n".into(), + }, + ); + + let reduced = client_apply_server_message(ClientApplyServerMessageRequest { + state: state.clone(), + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { "uri": "file:///tmp/project/Other.java", "diagnostics": [] } + }) + .to_string(), + }) + .unwrap(); + assert!(std::sync::Arc::ptr_eq( + &state.open_documents[uri].text, + &reduced.state.open_documents[uri].text + )); + + // Hosts and fixtures still see the text as a plain JSON string. + let serialized = serde_json::to_value(&reduced.state).unwrap(); + assert_eq!(serialized["openDocuments"][uri]["text"], "class Main {}\n"); + let restored: LspClientState = serde_json::from_value(serialized).unwrap(); + assert_eq!( + restored.open_documents[uri].text.as_ref(), + "class Main {}\n" + ); +} + #[test] fn client_core_waits_for_shutdown_response_before_exiting() { let mut state = LspClientState { @@ -1301,7 +1349,7 @@ fn client_core_waits_for_shutdown_response_before_exiting() { uri: "file:///tmp/project/main.go".to_string(), language_id: "go".to_string(), version: 1, - text: "package main\n".to_string(), + text: "package main\n".into(), }, ); From 8db5eba78d8bcee7bcb89bb75d22d6109e75a7da Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 28 Oct 2026 23:19:01 -0700 Subject: [PATCH 2/3] perf(jdt): key the workspace on build structure and tune the jdtls JVM The JDT -data fingerprint no longer includes build-file mtime or size. jdtls already detects pom and Gradle script edits itself (MavenProjectImporter.needsMavenUpdate, GradleProjectImporter. checkGradlePersistence), so switching to a fresh -data directory on every edit only turned an incremental update into a full re-import. The Gradle wrapper distribution stays part of the key because jdtls does not check it. jdtls now launches with the GC and logging defaults vscode-java and the upstream CI use (ParallelGC with GCTimeRatio=4 and AdaptiveSizePolicyWeight=90, -Xlog:disable, DetectVMInstallationsJob disabled). A plugin option with the same key replaces each default, and a plugin collector choice replaces ParallelGC. Co-Authored-By: Claude Opus 5.5 --- ...ceiling-and-duplicate-maven-coordinates.md | 27 +++- ...26-10-11-gradle-project-import-settings.md | 13 +- .../2026-10-14-declarative-plugin-settings.md | 2 +- macos/Resources/en.lproj/Localizable.strings | 2 - .../zh-Hans.lproj/Localizable.strings | 2 - .../Lithe/Core/Rust/RustCoreBridge.swift | 2 - .../Jdt/MacJdtWorkspaceState.swift | 20 +-- .../MacJdtWorkspaceStateTests.swift | 15 +- .../src/lsp/languages/java_workspace.rs | 69 +++++---- rust/lithe-core/src/lsp/languages/jdt.rs | 136 +++++++++++++++++- rust/lithe-core/src/tests/languages.rs | 1 + shared/contracts/rust-core-api.md | 21 ++- .../lsp/jdt-workspace-fingerprint-v1.json | 27 ++-- shared/i18n/locales/en-US.json | 1 - shared/i18n/locales/zh-CN.json | 1 - shared/i18n/messages.json | 16 --- windows/tauri/src-tauri/src/lsp.rs | 51 ++++++- .../tauri/src-tauri/src/lsp/jdt_workspace.rs | 116 ++++++++------- 18 files changed, 369 insertions(+), 153 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md b/.agents/notes/implemented/bug-fix/2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md index b23be678ce..149f90777d 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md +++ b/.agents/notes/implemented/bug-fix/2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md @@ -51,6 +51,20 @@ Lithe 在两条启动路径上都用 `-Xmx1024m` 启动内置 Java 语言服务 4. 两端共用同一个引擎(`rust/lithe-core/src/lsp/interface/engine.rs` 的 `start_server`),macOS 和 Windows 同时生效,平台代码不需要改。 +5. (2026-10-29 补充)堆上限抬到 2 GB 之后,JVM 默认的 G1 会长期持有接近上限的堆, + 导入结束也很少还给系统。`jdt.rs` 的 `JDTLS_TUNING_ARGUMENTS` 因此在两条启动路径 + 上追加 vscode-java 和 JDT LS 上游 CI 使用的同一组默认参数: + `-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90`(吞吐量 + 收集器,允许 20% 的 GC 时间,堆在导入完成后会收缩),`-Xlog:disable`(JVM 统一 + 日志的告警默认写 stdout,而 stdout 是 LSP 通道),以及 + `-DDetectVMInstallationsJob.disabled=true`(宿主已经通过 + `java.configuration.runtimes` 传入 JDK,不需要 JDT LS 启动时再扫描本机 JVM)。 + 插件参数里出现同一个键(`-XX:` 选项按名字,`-Xlog` 视为一个键,其余按 `=` 前的 + 部分)时用插件的值;插件选了任何收集器(`-XX:±Use*GC`)时去掉默认的 ParallelGC, + 因为 JVM 拒绝同时指定两个收集器。与默认值完全相同的参数视为宿主所有,重复适配的 + 结果不变。用例 `java_start_lets_plugin_jvm_options_replace_the_tuning_defaults` + 固定这条覆盖规则。 + 正确做法:把堆上限当成"语言服务容量"的产品决定,集中在 `jdt.rs` 的适配层。 不要这样做:为了降低内存占用把默认上限降回 1.5 GB 或更低,让插件设置绕过 1536 MiB @@ -59,6 +73,13 @@ Lithe 在两条启动路径上都用 `-Xmx1024m` 启动内置 Java 语言服务 ## 考虑过的备选方案 +- **照搬 openidea 的整套 JVM 参数(包括 `-Xms100m`、`-Daether.dependencyCollector.impl=bf` + 和 `-Dsun.zip.disableMemoryMapping=true`)**:部分否决。`-Xms` 继续由本 Note 固定为 + 256m;`aether` 收集器会改变 Maven 依赖解析策略,m2e 是否读取这个系统属性没有核实, + 不为可能的收益换未知的解析差异;`sun.zip.disableMemoryMapping` 在 JDK 9 以后不再 + 生效。顺带确认过:`-Dlog.protocol=true` 在 JDT LS 源码里没有读取方,是无效参数, + 删除它没有性能收益,所以保留现状。 + - **升级打包的 JDT LS 到含上游修复的版本**:否决。`download.eclipse.org/jdtls/milestones/` 当前最新仍是 1.61.0,官方只有会滚动的 snapshot,无法按 SHA-256 固定,不符合 `third_party/jdtls/manifest.json` 的固定版本加校验要求。上游修复进入下一个里程碑后 @@ -78,6 +99,9 @@ Lithe 在两条启动路径上都用 `-Xmx1024m` 启动内置 Java 语言服务 自己;JDT LS 同时拿到上游文档建议的堆,索引和编译大型工程的余量更充足。 - 代价:Java 语言服务 JVM 的堆上限从 1 GB 提到 2 GB。`-Xms` 仍是 256m,上限不是预留, 启动开销不变,只有工程确实需要时才会增长到 2 GB;代价是大工作区可能占用更多内存。 +- GC 默认值的代价:ParallelGC 的单次停顿比 G1 长,但 JDT LS 是后台服务,编辑器的 + 界面线程不在这个 JVM 里,换来的是空闲时更低的常驻内存。插件需要别的收集器时直接在 + 参数里指定即可。 - 残留:坐标完全相同的模块本来就无法都成为独立工程(目录名和坐标都重复),仍然只有 一个进入 Java 模型,其余被 m2e 跳过。1.61.0 不会提示这件事,上游下一个版本会用通知 列出被跳过的 `pom.xml`;用户需要自己修正重复坐标。 @@ -90,7 +114,8 @@ Lithe 在两条启动路径上都用 `-Xmx1024m` 启动内置 Java 语言服务 - 用例 `java_start_adds_runtime_memory_and_unique_data_arguments` 和 `java_direct_start_builds_complete_shell_free_arguments_and_is_stable` 固定两条启动 路径的完整参数列表,`java_start_keeps_the_heap_above_the_jdtls_constrained_memory_threshold` - 固定堆上限与 1.5 GB 阈值的关系。 + 固定堆上限与 1.5 GB 阈值的关系,`java_start_lets_plugin_jvm_options_replace_the_tuning_defaults` + 固定 GC 默认值的覆盖规则。 ## 适用范围 diff --git a/.agents/notes/implemented/feature/2026-10-11-gradle-project-import-settings.md b/.agents/notes/implemented/feature/2026-10-11-gradle-project-import-settings.md index 4f6a78cc27..1d3ab11a9f 100644 --- a/.agents/notes/implemented/feature/2026-10-11-gradle-project-import-settings.md +++ b/.agents/notes/implemented/feature/2026-10-11-gradle-project-import-settings.md @@ -4,7 +4,7 @@ ## 先说结论 -Lithe 打包的 JDT LS 已内置 Buildship,因此 Gradle 工程本就能被导入、索引与补全。本次改动不改这条链路,只把原本依赖 Buildship 默认值的 Gradle 导入显式化:平台侧探测工作区根是否声明 Gradle、是否带 wrapper 描述文件,Rust Core 据此在 `settings.java` 下发 `java.import.gradle`,并把 Gradle 工程的结构文件纳入工作区指纹。下发的两个开关与上游默认值相同,对今天的导入结果是空操作,它固定的是 Lithe 依赖的行为前提,并让 Gradle 与 Maven 走同一套「平台探测 → Core 组装 → JDT LS」结构。 +Lithe 打包的 JDT LS 已内置 Buildship,因此 Gradle 工程本就能被导入、索引与补全。本次改动不改这条链路,只把原本依赖 Buildship 默认值的 Gradle 导入显式化:平台侧探测工作区根是否声明 Gradle、是否带 wrapper 描述文件,Rust Core 据此在 `settings.java` 下发 `java.import.gradle`,并把 Gradle 工程的结构文件和 wrapper 的 Gradle 版本纳入工作区指纹。下发的两个开关与上游默认值相同,对今天的导入结果是空操作,它固定的是 Lithe 依赖的行为前提,并让 Gradle 与 Maven 走同一套「平台探测 → Core 组装 → JDT LS」结构。 ## 问题 @@ -20,7 +20,9 @@ Maven 工程有一套完整的显式设置链路:平台侧探测 `pom.xml` 与 - `StartServerRequest` 增加 `java_gradle` 字段,`RuntimeSession` 保存它,并在构造初始化选项与工作区配置时统一复用。wrapper 是工作区根在启动时的属性,会话生命周期内不变,所以只在启动时确定一次。 - 只下发 `enabled` 与 `wrapper.enabled` 这两项可以直接判定的布尔值。`java.home`、`version`、`offline.enabled` 等需要读取 Gradle 脚本或网络状态,Core 无法在启动时确定,一律不下发,交由 Buildship 自行解析。 - 例外是用户自己选的构建选项(2026-10-18):声明 `languageImport.kind: "gradle"` 的构建插件把离线和 Gradle User Home 映射到自己的选项,`build.languageImport` 解析成 `javaGradle.offline` / `userHome`,Core 只在用户设置了它们时下发 `offline.enabled` 和 `user.home`。这些是用户的选择,不是猜测,见[构建系统插件契约](../architecture/2026-10-17-build-system-plugin-contract.md)第 12 节。 -- Windows 的构建文件指纹(`windows/tauri/src-tauri/src/lsp/jdt_workspace.rs`)加入 `settings.gradle`、`settings.gradle.kts`(模块结构变化,对应 Maven 的直接模块扫描)与 `gradle/wrapper/gradle-wrapper.properties`(Gradle 版本所在)。`gradle.properties` 刻意不加入:mod 工程几乎每次版本提升都会改写它,一旦纳入指纹,每次改动都会换用新的 JDT 数据目录并冷导入一次。会话中途的构建文件变更由 Core 的 `classify_change` 负责。 +- Windows 的构建文件指纹(`windows/tauri/src-tauri/src/lsp/jdt_workspace.rs`)记录 `settings.gradle`、`settings.gradle.kts` 与 `gradle/wrapper/gradle-wrapper.properties` **是否存在**,并单独读出 wrapper 的 `distributionUrl`(也就是 Gradle 版本)交给 Core。`gradle.properties` 刻意不加入。会话中途的构建文件变更由 Core 的 `classify_change` 分类后转发给 JDT LS。 +- 指纹(决定 JDT 数据目录 `-data` 用哪个)只看结构,不看构建文件的修改时间和大小(2026-10-29 修订)。原因是 JDT LS 自己会处理内容变化:重启时它把每个 `pom.xml`、`*.gradle`、`*.gradle.kts` 的修改时间和上次保存的状态比较,只更新改过的项目(上游 `MavenProjectImporter.needsMavenUpdate`、`GradleProjectImporter.checkGradlePersistence`);运行中则通过 `workspace/didChangeWatchedFiles` 收到变化,`updateBuildConfiguration` 为 `automatic`。如果把修改时间放进指纹,改一下 pom、切一次分支都会换新目录,整个索引从零重建,这正是打开项目时"又在重新解析"的主要来源。 +- 例外是 Gradle 版本:JDT LS 的检查不包含 wrapper 描述文件,所以 wrapper 的 `distributionUrl` 进入指纹,版本变了才换新目录。读值时去掉 properties 格式的转义(`https\://` 读成 `https://`),同一个版本总是得到同一个值。 TS 侧只做透传:`JavaLspLaunch` 增加可选的 `javaGradle`,`resolve-editor-lsp-launch.ts`、`lsp-core-adapter.ts`、`lsp-client.ts` 原样转发到核心的 `lsp.startServer`,不新增判断逻辑。 @@ -36,16 +38,19 @@ TS 侧只做透传:`JavaLspLaunch` 增加可选的 `javaGradle`,`resolve-edi 把 `gradle.properties` 与 `gradlew` 一并纳入指纹。否决:前者在 mod 工程里变动频繁,纳入后每次版本提升都会冷导入一次;后者并不携带 Gradle 版本信息。收益不足以抵消代价。 +用构建文件的修改时间和大小区分工作区状态(2026-10-29 前的做法)。否决:内容变化本来由 JDT LS 判断,再按时间戳换目录等于在上游之外维护第二份"是否过期"的判断,代价是每次编辑都冷导入。只有 JDT LS 看不到的 Gradle 版本由 Lithe 补上。 + 让 macOS 侧也补 wrapper 探测与指纹扩展。本次不做:改动范围限定为 Rust 内核与 Windows;macOS 依赖 Buildship 默认值即可导入 Gradle 工程,功能不缺,只是在显式声明与指纹覆盖上暂不对称。 ## 后果 -Gradle 与 Maven 在导入设置上结构对称,行为可预期、可扩展。Windows 下发的两个开关与 Buildship 默认值一致,导入结果不变;真正改变行为的是工作区指纹:`settings.gradle` 或 wrapper 描述文件变化会换用新的 JDT 数据目录并重新导入,而改写 `gradle.properties` 不会。macOS 在 2026-10-18 经构建插件 `languageImport` 接入显式设置:`BuildSystemService` 等待 Core 归一化后,把 `javaGradle` 经语言会话和 Rust Swift 桥传给启动请求,离线和 User Home 选项等下次会话启动生效。没有插件输入时保留 Buildship 默认行为。macOS 尚未扩展指纹(`MacJdtWorkspaceState.buildFileNames` 仍只覆盖 `pom.xml`、`build.gradle`、`build.gradle.kts`),同一工程在两个平台上的指纹含义并不相同;真实 macOS 导入验收也仍待完成。若工作区根未声明 Gradle 构建文件,Core 不会下发任何 `java.import` 设置,纯 Maven 工程不受影响。 +Gradle 与 Maven 在导入设置上结构对称,行为可预期、可扩展。Windows 下发的两个开关与 Buildship 默认值一致,导入结果不变;真正改变行为的是工作区指纹:新增或删除 `settings.gradle`、wrapper 描述文件,或 wrapper 的 Gradle 版本变化,会换用新的 JDT 数据目录并重新导入;编辑这些文件的其他内容、改写 `gradle.properties` 都不会,由 JDT LS 自己增量更新。指纹格式在 2026-10-29 修订时变化,升级后每个工作区会重建一次索引。macOS 在 2026-10-18 经构建插件 `languageImport` 接入显式设置:`BuildSystemService` 等待 Core 归一化后,把 `javaGradle` 经语言会话和 Rust Swift 桥传给启动请求,离线和 User Home 选项等下次会话启动生效。没有插件输入时保留 Buildship 默认行为。macOS 尚未扩展指纹(`MacJdtWorkspaceState.buildFileNames` 仍只覆盖 `pom.xml`、`build.gradle`、`build.gradle.kts`),同一工程在两个平台上的指纹含义并不相同;真实 macOS 导入验收也仍待完成。若工作区根未声明 Gradle 构建文件,Core 不会下发任何 `java.import` 设置,纯 Maven 工程不受影响。 ## 验证 - `rust/lithe-core` 单测覆盖:初始化选项在导入前即携带 Gradle 设置、无 wrapper 描述文件时省略 wrapper 字段、无 Gradle 上下文时不下发 `java.import`、按 section 查询返回对应 Gradle 取值、叶子合并保留调用方已声明的 `offline` 与 `version`。 -- `windows/tauri/src-tauri/src/lsp.rs` 单测覆盖:工作区指纹识别 Gradle 设置文件与 wrapper 描述文件且忽略 `gradle.properties`、根目录缺少构建文件时不判为 Gradle、仅有 `gradlew` 脚本时不认为存在 wrapper、Maven 工作区不产生 Gradle 上下文。 +- `windows/tauri/src-tauri/src/lsp.rs` 单测覆盖:工作区指纹识别 Gradle 设置文件与 wrapper 描述文件且忽略 `gradle.properties`、编辑构建文件不改变观测结果、wrapper 版本(含转义写法)被读出且版本变化会改变指纹、根目录缺少构建文件时不判为 Gradle、仅有 `gradlew` 脚本时不认为存在 wrapper、Maven 工作区不产生 Gradle 上下文。 +- 共享 fixture `shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json` 由 `rust/lithe-core` 的 `jdt_workspace_fingerprint_matches_the_shared_compatibility_fixture` 校验,含带 wrapper 版本的用例;macOS 的 `MacJdtWorkspaceStateTests` 校验编辑 `pom.xml` 后观测结果不变。 - `windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts` 与 `windows/tauri/src/platform/lsp-core-adapter.test.ts` 覆盖 TS 透传:有 Gradle 上下文时原样转发,无上下文时为 `null`。 - `scripts/rust-core/verify-rust-core.sh`、`scripts/rust-core/verify-rust-core-comments.sh`、`scripts/rust-core/verify-rust-core-layout.sh` 校验 Rust 核心的格式、注释与包布局。 - `scripts/platform-feature-matrix/verify-platform-feature-matrix.sh`、`scripts/platform-feature-matrix/verify-platform-feature-matrix-change.sh` 与 `scripts/agent-notes/verify-agent-notes.sh` 校验功能矩阵与 Note 记录。 diff --git a/.agents/notes/implemented/feature/2026-10-14-declarative-plugin-settings.md b/.agents/notes/implemented/feature/2026-10-14-declarative-plugin-settings.md index 7c3b136845..f8bfc8f132 100644 --- a/.agents/notes/implemented/feature/2026-10-14-declarative-plugin-settings.md +++ b/.agents/notes/implemented/feature/2026-10-14-declarative-plugin-settings.md @@ -39,7 +39,7 @@ JDT LS 这类语言服务器有很多启动参数,最常见的是堆内存上 ### 4. JDT 适配层让出两个参数 -直接启动 JDT 时,宿主以前会丢弃插件传来的 `-Xmx` 和 `-Dlog.level=`,再塞自己的默认值。现在这两个参数改为“插件的最后一个合法值优先,没有就用宿主默认值(`-Xmx2048m`、`-Dlog.level=ALL`,即以前的行为)”。合法性很窄:堆大小必须是数字加可选的 `k`/`m`/`g`,并且必须大于 1536 MiB(JDT LS 在不超过 1.5 GB 时会改走“分批导入”,模块坐标重复会让整个 Java 服务失败,见 `2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md`);日志级别必须是字母数字;不合法的值被丢弃而不是传给 JVM。Java Support 的清单因此把 `heapMaxMB` 的下限和默认值都设为 2048。`-Xms`、`--add-*`、Eclipse 产品属性、元数据位置和 Lombok 仍归宿主所有。 +直接启动 JDT 时,宿主以前会丢弃插件传来的 `-Xmx` 和 `-Dlog.level=`,再塞自己的默认值。现在这两个参数改为“插件的最后一个合法值优先,没有就用宿主默认值(`-Xmx2048m`、`-Dlog.level=ALL`,即以前的行为)”。合法性很窄:堆大小必须是数字加可选的 `k`/`m`/`g`,并且必须大于 1536 MiB(JDT LS 在不超过 1.5 GB 时会改走“分批导入”,模块坐标重复会让整个 Java 服务失败,见 `2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md`);日志级别必须是字母数字;不合法的值被丢弃而不是传给 JVM。Java Support 的清单因此把 `heapMaxMB` 的下限和默认值都设为 2048。`-Xms`、`--add-*`、Eclipse 产品属性、元数据位置和 Lombok 仍归宿主所有。宿主另外追加一组 GC 与日志默认值,插件参数里同键的值优先(见 `2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md` 决策第 5 条)。 这个读取是幂等的:宿主把自己产出的参数再喂回适配层,得到相同的结果,已有的稳定性测试继续成立。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 7ecd815421..9191943fb9 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -976,7 +976,6 @@ "Could not prepare the run configuration: %@" = "Could not prepare the run configuration: %@"; "Could not read %@." = "Could not read %@."; "Could not read %@: %@" = "Could not read %@: %@"; -"Could not read Java build-file metadata for %@." = "Could not read Java build-file metadata for %@."; "Could not read the command line." = "Could not read the command line."; "Could not read the project folder. Check that it still exists and that Lithe has permission to access it." = "Could not read the project folder. Check that it still exists and that Lithe has permission to access it."; "Could not read the selected built-in background." = "Could not read the selected built-in background."; @@ -5421,7 +5420,6 @@ "native.couldNotPrepareTheRunConfiguration" = "Could not prepare the run configuration: %1$@"; "native.couldNotRead" = "Could not read %1$@: %2$@"; "native.couldNotRead.variant2" = "Could not read %1$@."; -"native.couldNotReadJavaBuildFileMetadataFor" = "Could not read Java build-file metadata for %1$@."; "native.couldNotReadTheCommandLine" = "Could not read the command line."; "native.couldNotReadTheProjectFolderCheckThatItStillExistsAndThatLitheHasPermissionToAccessIt" = "Could not read the project folder. Check that it still exists and that Lithe has permission to access it."; "native.couldNotReadTheSelectedBuiltInBackground" = "Could not read the selected built-in background."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 9b49a019e6..10f597f367 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -976,7 +976,6 @@ "Could not prepare the run configuration: %@" = "无法准备运行配置:%@"; "Could not read %@." = "无法读取 %@。"; "Could not read %@: %@" = "无法读取 %@:%@"; -"Could not read Java build-file metadata for %@." = "无法读取 %@ 的 Java 构建文件元数据。"; "Could not read the command line." = "无法读取命令行。"; "Could not read the project folder. Check that it still exists and that Lithe has permission to access it." = "无法读取项目文件夹,请确认文件夹仍然存在并允许 Lithe 访问。"; "Could not read the selected built-in background." = "无法读取所选的内置背景。"; @@ -5421,7 +5420,6 @@ "native.couldNotPrepareTheRunConfiguration" = "无法准备运行配置:%1$@"; "native.couldNotRead" = "无法读取 %1$@:%2$@"; "native.couldNotRead.variant2" = "无法读取 %1$@。"; -"native.couldNotReadJavaBuildFileMetadataFor" = "无法读取 %1$@ 的 Java 构建文件元数据。"; "native.couldNotReadTheCommandLine" = "无法读取命令行。"; "native.couldNotReadTheProjectFolderCheckThatItStillExistsAndThatLitheHasPermissionToAccessIt" = "无法读取项目文件夹,请确认文件夹仍然存在并允许 Lithe 访问。"; "native.couldNotReadTheSelectedBuiltInBackground" = "无法读取所选的内置背景。"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 40e464ee49..470a2b0d57 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1843,8 +1843,6 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { struct JdtBuildFileObservation: Encodable, Equatable, Sendable { let path: String - let modifiedUnixMilliseconds: UInt64 - let sizeBytes: UInt64 } private struct JdtWorkspaceFingerprintRequest: Encodable { diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/HostIntegrations/Jdt/MacJdtWorkspaceState.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/HostIntegrations/Jdt/MacJdtWorkspaceState.swift index 61479a1ad3..c10dfcfef3 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/HostIntegrations/Jdt/MacJdtWorkspaceState.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/HostIntegrations/Jdt/MacJdtWorkspaceState.swift @@ -90,20 +90,11 @@ struct MacJdtWorkspaceState { for name in Self.buildFileNames { let path = root.appendingPathComponent(name).path + // Only presence counts; JDT LS detects edits itself (see the + // `java.jdtWorkspaceFingerprint` contract). do { - let attributes = try fileManager.attributesOfItem(atPath: path) - guard let modified = attributes[.modificationDate] as? Date, - let size = attributes[.size] as? NSNumber else { - throw MacJdtWorkspaceStateError.invalidBuildFileMetadata(name) - } - buildFiles.append(RustCoreBridge.JdtBuildFileObservation( - path: name, - modifiedUnixMilliseconds: UInt64(max( - 0, - (modified.timeIntervalSince1970 * 1_000).rounded(.down) - )), - sizeBytes: size.uint64Value - )) + _ = try fileManager.attributesOfItem(atPath: path) + buildFiles.append(RustCoreBridge.JdtBuildFileObservation(path: name)) } catch where isMissingFileError(error) { continue } @@ -378,7 +369,6 @@ struct MacJdtCacheCleanupResult: Equatable { private enum MacJdtWorkspaceStateError: LocalizedError { case workspaceFingerprintUnavailable - case invalidBuildFileMetadata(String) case invalidLanguageServerManifest(String) case languageServerVersionUnavailable case languageServerInstallationUnavailable @@ -391,8 +381,6 @@ private enum MacJdtWorkspaceStateError: LocalizedError { switch self { case .workspaceFingerprintUnavailable: L10n.string("Rust Core workspace-fingerprint resolution is unavailable.") - case .invalidBuildFileMetadata(let path): - L10n.string("Could not read Java build-file metadata for \(path).") case .invalidLanguageServerManifest(let path): L10n.string("The bundled JDT LS manifest is invalid at \(path).") case .languageServerVersionUnavailable: diff --git a/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift b/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift index 404df7b515..f861cf3c9b 100644 --- a/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift +++ b/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift @@ -52,17 +52,18 @@ struct MacJdtWorkspaceStateTests { at: fixture.workspaceURL, languageServerExecutableURL: fixture.executableURL ) - let initialPomSize = capture.last?.buildFiles.first?.sizeBytes - #expect(capture.last?.buildFiles.map(\.path) == ["pom.xml"]) - try write( - "a larger project descriptor", - to: fixture.workspaceURL.appendingPathComponent("pom.xml") - ) + let beforeEdit = capture.last?.buildFiles + #expect(beforeEdit?.map(\.path) == ["pom.xml"]) + // Editing a descriptor keeps the JDT LS state: JDT LS updates the + // changed projects itself instead of reimporting the workspace. + let pomURL = fixture.workspaceURL.appendingPathComponent("pom.xml") + try write("a larger project descriptor", to: pomURL) + try setModificationDate(Date(timeIntervalSince1970: 2_000_000_000), for: pomURL) _ = try state.fingerprint( at: fixture.workspaceURL, languageServerExecutableURL: fixture.executableURL ) - #expect((capture.last?.buildFiles.first?.sizeBytes ?? 0) > (initialPomSize ?? 0)) + #expect(capture.last?.buildFiles == beforeEdit) for moduleName in ["zeta", "alpha"] { let moduleURL = fixture.workspaceURL.appendingPathComponent( diff --git a/rust/lithe-core/src/lsp/languages/java_workspace.rs b/rust/lithe-core/src/lsp/languages/java_workspace.rs index 4c58323fbe..1ecdf5c1c3 100644 --- a/rust/lithe-core/src/lsp/languages/java_workspace.rs +++ b/rust/lithe-core/src/lsp/languages/java_workspace.rs @@ -142,20 +142,29 @@ pub(crate) struct JdtWorkspaceFingerprintRequest { /// Names of direct child directories that contain a Maven descriptor. #[serde(default)] pub direct_maven_modules: Vec, + /// `distributionUrl` of the root `gradle/wrapper/gradle-wrapper.properties`, + /// when present. Unlike edits to build scripts, which JDT LS notices on + /// restart, a Gradle version change is invisible to its import check, so + /// the version itself selects a fresh state directory. + #[serde(default)] + pub gradle_wrapper_distribution: Option, /// JDT LS version resolved by the platform package adapter. pub jdtls_version: String, } #[derive(Debug, Clone, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Filesystem metadata for one root-level Java build descriptor. +/// One root-level Java build descriptor that exists in the workspace. +/// +/// Only its presence counts. Edits to a descriptor are JDT LS's to detect: on a +/// restart it compares each POM and Gradle file's timestamp with the state it +/// saved and updates only the projects that changed, and while running it +/// receives the change through `workspace/didChangeWatchedFiles`. Mixing the +/// timestamp into the state-directory name would discard the whole index after +/// any touch of the file, a `git checkout` included. pub(crate) struct JdtBuildFileObservation { /// Workspace-relative build descriptor path with `/` separators. pub path: String, - /// Last modification time expressed as Unix epoch milliseconds. - pub modified_unix_milliseconds: u64, - /// File size in bytes. - pub size_bytes: u64, } #[derive(Debug, Clone, Eq, PartialEq, Serialize)] @@ -215,31 +224,36 @@ pub(crate) fn jdt_workspace_fingerprint( )); } - let mut build_files = BTreeMap::new(); - for observation in request.build_files { - let path = normalize_relative_path(&observation.path)?; - build_files.insert( - path, - ( - observation.modified_unix_milliseconds, - observation.size_bytes, - ), - ); - } + let build_files = request + .build_files + .into_iter() + .map(|observation| normalize_relative_path(&observation.path)) + .collect::, _>>()?; let modules = request .direct_maven_modules .into_iter() .map(|module| normalize_module_name(&module)) .collect::, _>>()?; - let build_files = build_files - .into_iter() - .map(|(path, (modified, size))| format!("{path}@{modified}:{size}")) - .collect::>() - .join(","); + let build_files = build_files.into_iter().collect::>().join(","); let modules = modules.into_iter().collect::>().join(","); + let mut workspace_fingerprint = + format!("build={build_files}|modules={modules}|jdtls={version}"); + if let Some(distribution) = request.gradle_wrapper_distribution { + let distribution = distribution.trim(); + if distribution.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The Gradle wrapper distribution must not be empty.", + )); + } + // Appended only when present, so workspaces without a wrapper keep + // the state directory they already have. + workspace_fingerprint.push_str("|gradle="); + workspace_fingerprint.push_str(distribution); + } Ok(JdtWorkspaceFingerprintResponse { - workspace_fingerprint: format!("build={build_files}|modules={modules}|jdtls={version}"), + workspace_fingerprint, }) } @@ -491,22 +505,29 @@ mod tests { JdtWorkspaceFingerprintRequest { build_files: vec![JdtBuildFileObservation { path: "../pom.xml".to_string(), - modified_unix_milliseconds: 1, - size_bytes: 1, }], direct_maven_modules: Vec::new(), + gradle_wrapper_distribution: None, jdtls_version: "1.38.0".to_string(), }, JdtWorkspaceFingerprintRequest { build_files: Vec::new(), direct_maven_modules: vec!["nested/core".to_string()], + gradle_wrapper_distribution: None, jdtls_version: "1.38.0".to_string(), }, JdtWorkspaceFingerprintRequest { build_files: Vec::new(), direct_maven_modules: Vec::new(), + gradle_wrapper_distribution: None, jdtls_version: " ".to_string(), }, + JdtWorkspaceFingerprintRequest { + build_files: Vec::new(), + direct_maven_modules: Vec::new(), + gradle_wrapper_distribution: Some(" ".to_string()), + jdtls_version: "1.38.0".to_string(), + }, ] { let error = jdt_workspace_fingerprint(request) .expect_err("invalid fingerprint observations must be rejected"); diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index a396d984fe..36f9f43259 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -813,6 +813,22 @@ const MAX_HEAP_PREFIX: &str = "-Xmx"; /// Language-server log level used when the plugin does not choose one. const DEFAULT_LOG_LEVEL_ARGUMENT: &str = "-Dlog.level=ALL"; const LOG_LEVEL_PREFIX: &str = "-Dlog.level="; +/// JVM defaults vscode-java and the JDT LS CI launch the language server with. +/// +/// The throughput collector with a 20% GC-time budget and a slowly adapting +/// size policy returns heap to the OS once an import settles, instead of +/// holding the 2 GB ceiling for the life of the session. `-Xlog:disable` keeps +/// JVM warnings off stdout, which carries the LSP stream. The installed-VM +/// scan is redundant because the host sends `java.configuration.runtimes`. +/// A plugin option with the same key replaces the default; see +/// `tuning_arguments`. +const JDTLS_TUNING_ARGUMENTS: [&str; 5] = [ + "-XX:+UseParallelGC", + "-XX:GCTimeRatio=4", + "-XX:AdaptiveSizePolicyWeight=90", + "-Xlog:disable", + "-DDetectVMInstallationsJob.disabled=true", +]; /// Removes every argument starting with `prefix` and returns the last one /// that is acceptable. The JVM honors the last occurrence, so that is the one @@ -859,6 +875,58 @@ fn is_heap_size(value: &str) -> bool { mebibytes > CONSTRAINED_HEAP_MEBIBYTES } +/// Returns the tuning defaults the plugin's JVM options leave unset. An option +/// with the same key replaces a default, and any garbage-collector selection +/// replaces the default collector, since the JVM refuses two collectors. +fn tuning_arguments(custom_jvm_arguments: &[String]) -> Vec { + let chooses_collector = custom_jvm_arguments + .iter() + .any(|argument| is_collector_selection(argument)); + JDTLS_TUNING_ARGUMENTS + .iter() + .filter(|default| !(chooses_collector && is_collector_selection(default))) + .filter(|default| { + let key = jvm_option_key(default); + !custom_jvm_arguments + .iter() + .any(|argument| jvm_option_key(argument) == key) + }) + .map(|default| default.to_string()) + .collect() +} + +/// A tuning default exactly as the host writes it. It is host-owned, so a +/// relaunch rebuilds it instead of reading it back as a plugin choice. +fn is_tuning_default(argument: &str) -> bool { + JDTLS_TUNING_ARGUMENTS.contains(&argument) +} + +fn is_collector_selection(argument: &str) -> bool { + argument + .strip_prefix("-XX:") + .and_then(|option| option.strip_prefix(['+', '-'])) + .is_some_and(|option| option.starts_with("Use") && option.ends_with("GC")) +} + +/// The part of a JVM option that identifies it regardless of its value: +/// `-XX:` options by name without the `+`/`-` toggle, every `-Xlog` option as +/// one key because they combine into a single logging configuration, and other +/// options by the text before `=`. +fn jvm_option_key(argument: &str) -> String { + if let Some(option) = argument.strip_prefix("-XX:") { + let option = option.trim_start_matches(['+', '-']); + let name = option.split_once('=').map_or(option, |(name, _)| name); + return format!("-XX:{name}"); + } + if argument.starts_with("-Xlog") { + return "-Xlog".to_string(); + } + argument + .split_once('=') + .map_or(argument, |(name, _)| name) + .to_string() +} + fn is_log_level(value: &str) -> bool { !value.is_empty() && value @@ -878,6 +946,8 @@ fn wrapper_arguments( .collect(); let max_heap = take_plugin_choice(&mut jvm_arguments, MAX_HEAP_PREFIX, is_heap_size) .unwrap_or_else(|| DEFAULT_MAX_HEAP_ARGUMENT.to_string()); + jvm_arguments.retain(|argument| !is_tuning_default(argument)); + let tuning = tuning_arguments(&jvm_arguments); let mut arguments = without_wrapper_owned_arguments(arguments); if let Some(java_executable) = java_executable { arguments.push("--java-executable".to_string()); @@ -886,6 +956,13 @@ fn wrapper_arguments( arguments.extend([ "--jvm-arg=-Xms256m".to_string(), format!("--jvm-arg={max_heap}"), + ]); + arguments.extend( + tuning + .into_iter() + .map(|argument| format!("--jvm-arg={argument}")), + ); + arguments.extend([ format!("--jvm-arg={METADATA_AT_PROJECT_ROOT_PROPERTY}=false"), "-data".to_string(), data_directory.to_string_lossy().into_owned(), @@ -911,6 +988,9 @@ fn direct_java_arguments( ), "-Xms256m".to_string(), max_heap, + ]; + adapted.extend(tuning_arguments(&custom_jvm_arguments)); + adapted.extend([ "--add-modules=ALL-SYSTEM".to_string(), "--add-opens=java.base/java.util=ALL-UNNAMED".to_string(), "--add-opens=java.base/java.lang=ALL-UNNAMED".to_string(), @@ -920,7 +1000,7 @@ fn direct_java_arguments( "-Dlog.protocol=true".to_string(), log_level, format!("{METADATA_AT_PROJECT_ROOT_PROPERTY}=false"), - ]; + ]); adapted.extend(custom_jvm_arguments); adapted.extend([ "-jar".to_string(), @@ -1025,6 +1105,7 @@ fn is_jdt_owned_jvm_argument(argument: &str) -> bool { || argument.starts_with("-Declipse.product=") || argument.starts_with("-Dosgi.bundles.defaultStartLevel=") || argument.starts_with("-Dlog.protocol=") + || is_tuning_default(argument) || is_metadata_location_argument(argument) || is_lombok_agent_argument(argument) } @@ -1061,9 +1142,9 @@ fn without_wrapper_owned_arguments(arguments: &[String]) -> Vec { || argument.starts_with("-data=") || argument.starts_with("--jvm-arg=-Xms") || argument.starts_with("--jvm-arg=-Xmx") - || argument - .strip_prefix("--jvm-arg=") - .is_some_and(is_metadata_location_argument); + || argument.strip_prefix("--jvm-arg=").is_some_and(|value| { + is_metadata_location_argument(value) || is_tuning_default(value) + }); if owns_following_value { index += usize::from(index + 1 < arguments.len()) + 1; } else { @@ -1623,6 +1704,11 @@ mod tests { "/jdk/bin/java", "--jvm-arg=-Xms256m", "--jvm-arg=-Xmx2048m", + "--jvm-arg=-XX:+UseParallelGC", + "--jvm-arg=-XX:GCTimeRatio=4", + "--jvm-arg=-XX:AdaptiveSizePolicyWeight=90", + "--jvm-arg=-Xlog:disable", + "--jvm-arg=-DDetectVMInstallationsJob.disabled=true", "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false", "-data", data_directory.to_string_lossy().as_ref() @@ -1630,6 +1716,43 @@ mod tests { ); } + #[test] + fn java_start_lets_plugin_jvm_options_replace_the_tuning_defaults() { + let tuning_of = |arguments: &[String]| { + arguments + .iter() + .filter_map(|argument| argument.strip_prefix("--jvm-arg=")) + .filter(|argument| argument.starts_with("-XX:") || argument.starts_with("-Xlog")) + .map(str::to_string) + .collect::>() + }; + let mut context = java_start_context(); + context.arguments = vec![ + "--jvm-arg=-XX:+UseG1GC".to_string(), + "--jvm-arg=-XX:GCTimeRatio=9".to_string(), + "--jvm-arg=-Xlog:gc*:file=/tmp/gc.log".to_string(), + ]; + let first = adapt_start(&context); + context.arguments = first.arguments.clone(); + let second = adapt_start(&context); + + assert_eq!(first, second); + // The plugin's collector replaces ParallelGC, and its own GC-time + // ratio and logging replace the host's; the untouched default stays. + assert_eq!( + tuning_of(&first.arguments), + vec![ + "-XX:+UseG1GC", + "-XX:GCTimeRatio=9", + "-Xlog:gc*:file=/tmp/gc.log", + "-XX:AdaptiveSizePolicyWeight=90", + ] + ); + assert!(first + .arguments + .contains(&"--jvm-arg=-DDetectVMInstallationsJob.disabled=true".to_string())); + } + #[test] fn java_start_uses_the_plugins_heap_choice_and_is_stable() { let mut context = java_start_context(); @@ -1748,6 +1871,11 @@ mod tests { "-javaagent:/jdtls/lombok/lombok.jar", "-Xms256m", "-Xmx4g", + "-XX:+UseParallelGC", + "-XX:GCTimeRatio=4", + "-XX:AdaptiveSizePolicyWeight=90", + "-Xlog:disable", + "-DDetectVMInstallationsJob.disabled=true", "--add-modules=ALL-SYSTEM", "--add-opens=java.base/java.util=ALL-UNNAMED", "--add-opens=java.base/java.lang=ALL-UNNAMED", diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs index 073a51078c..9a5510b474 100644 --- a/rust/lithe-core/src/tests/languages.rs +++ b/rust/lithe-core/src/tests/languages.rs @@ -117,6 +117,7 @@ fn jdt_workspace_fingerprint_matches_the_shared_compatibility_fixture() { "payload": { "buildFiles": case["buildFiles"], "directMavenModules": case["directMavenModules"], + "gradleWrapperDistribution": case["gradleWrapperDistribution"], "jdtlsVersion": case["jdtlsVersion"] } }); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 71557ab7b4..6553610359 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -2649,16 +2649,23 @@ Packaged macOS and Windows plans always use structured direct launch, so runtime startup has no shell, PowerShell, or user-`PATH` dependency. Wrapper launch remains optional only for external or older plans. -For JDT LS, platform adapters observe root Maven/Gradle descriptor timestamps -and sizes, names of direct Maven module directories, and the selected JDT LS -version. They submit those raw observations to `java.jdtWorkspaceFingerprint`; +For JDT LS, platform adapters observe which root Maven/Gradle descriptors +exist, names of direct Maven module directories, the Gradle wrapper's +`distributionUrl` when the workspace has one, and the selected JDT LS version. They submit those raw observations to `java.jdtWorkspaceFingerprint`; Rust Core validates, sorts, de-duplicates, and constructs the sole portable fingerprint representation. Core then hashes the normalized workspace identity followed by a null separator and that opaque fingerprint to select `cacheDirectory/jdtls/`. Omitting the fingerprint preserves the legacy path-only key for older clients. Changing structure selects a new directory without deleting the old one, so a -later switch back can reuse it. +later switch back can reuse it. Editing a descriptor is not a structural change: +JDT LS compares each POM and Gradle file's timestamp with the state it saved and +updates only the changed projects on restart, and running sessions receive the +edit through `lsp.workspaceFilesChanged`. Keying the directory on descriptor +timestamps would discard the whole index after any touch of the file. The Gradle +version is the exception: JDT LS does not check the wrapper descriptor, so the +wrapper's `distributionUrl` takes part in the fingerprint and a version change +selects a fresh directory. Core starts JDT LS with `-Djava.import.generatesMetadataFilesAtProjectRoot=false`, so the Eclipse @@ -2676,9 +2683,9 @@ at the removed files, and records the removed workspace-relative paths in the session's `info` log event. `java.jdtWorkspaceFingerprint` accepts -`{ buildFiles, directMavenModules, jdtlsVersion }`. Each build-file observation -contains a workspace-relative `path`, `modifiedUnixMilliseconds`, and -`sizeBytes`. It returns `{ workspaceFingerprint }`; platforms must not recreate +`{ buildFiles, directMavenModules, gradleWrapperDistribution?, jdtlsVersion }`. +Each build-file observation contains only a workspace-relative `path`; +`gradleWrapperDistribution`, when sent, must not be blank. It returns `{ workspaceFingerprint }`; platforms must not recreate or parse this opaque string. Compatibility cases are in `shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json`. diff --git a/shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json b/shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json index 023fcc900d..2e0103d8bc 100644 --- a/shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json +++ b/shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json @@ -4,20 +4,25 @@ { "name": "sorts-and-deduplicates-platform-observations", "buildFiles": [ - { - "path": "pom.xml", - "modifiedUnixMilliseconds": 1700000000000, - "sizeBytes": 42 - }, - { - "path": "build.gradle.kts", - "modifiedUnixMilliseconds": 1700000000500, - "sizeBytes": 84 - } + { "path": "pom.xml" }, + { "path": "build.gradle.kts" }, + { "path": "pom.xml" } ], "directMavenModules": ["core", "api", "core"], "jdtlsVersion": "1.38.0", - "workspaceFingerprint": "build=build.gradle.kts@1700000000500:84,pom.xml@1700000000000:42|modules=api,core|jdtls=1.38.0" + "workspaceFingerprint": "build=build.gradle.kts,pom.xml|modules=api,core|jdtls=1.38.0" + }, + { + "name": "gradle-wrapper-version-selects-the-state", + "buildFiles": [ + { "path": "settings.gradle.kts" }, + { "path": "build.gradle.kts" }, + { "path": "gradle/wrapper/gradle-wrapper.properties" } + ], + "directMavenModules": [], + "gradleWrapperDistribution": "https://services.gradle.org/distributions/gradle-8.10-bin.zip", + "jdtlsVersion": "1.38.0", + "workspaceFingerprint": "build=build.gradle.kts,gradle/wrapper/gradle-wrapper.properties,settings.gradle.kts|modules=|jdtls=1.38.0|gradle=https://services.gradle.org/distributions/gradle-8.10-bin.zip" }, { "name": "version-only-workspace", diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 60240512a0..94f7896a68 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9047,7 +9047,6 @@ "native.unableToSaveWorkspaceDependencyConfiguration": "Unable to save workspace dependency configuration: {arg1}", "native.theJavaSupportPluginDoesNotProvideReinstall": "The Java Support plugin does not provide {arg1}. Reinstall the plugin.", "native.rustCoreWorkspaceFingerprintResolutionIsUnavailable": "Rust Core workspace-fingerprint resolution is unavailable.", - "native.couldNotReadJavaBuildFileMetadataFor": "Could not read Java build-file metadata for {arg1}.", "native.theBundledJdtLsManifestIsInvalidAt": "The bundled JDT LS manifest is invalid at {arg1}.", "native.theBundledJdtLsVersionCouldNotBe": "The bundled JDT LS version could not be determined.", "native.theBundledJdtLsInstallationCouldNotBe": "The bundled JDT LS installation could not be determined.", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 1ae4f42768..affda5ea99 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9047,7 +9047,6 @@ "native.unableToSaveWorkspaceDependencyConfiguration": "无法保存工作区依赖配置:{arg1}", "native.theJavaSupportPluginDoesNotProvideReinstall": "Java 支持插件没有提供 {arg1}。请重新安装该插件。", "native.rustCoreWorkspaceFingerprintResolutionIsUnavailable": "Rust Core 的工作区指纹解析不可用。", - "native.couldNotReadJavaBuildFileMetadataFor": "无法读取 {arg1} 的 Java 构建文件元数据。", "native.theBundledJdtLsManifestIsInvalidAt": "{arg1} 处内置的 JDT LS 清单无效。", "native.theBundledJdtLsVersionCouldNotBe": "无法确定内置 JDT LS 的版本。", "native.theBundledJdtLsInstallationCouldNotBe": "无法确定内置 JDT LS 的安装位置。", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index f07157018e..a2adc66f86 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -84189,22 +84189,6 @@ "arguments": [] } }, - { - "id": "native.couldNotReadJavaBuildFileMetadataFor", - "description": "Native UI: Could not read Java build-file metadata for %@.", - "platforms": [ - "macos" - ], - "macos": { - "key": "Could not read Java build-file metadata for %@.", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } - }, { "id": "native.theBundledJdtLsManifestIsInvalidAt", "description": "Native UI: The bundled JDT LS manifest is invalid at %@.", diff --git a/windows/tauri/src-tauri/src/lsp.rs b/windows/tauri/src-tauri/src/lsp.rs index bf636d37b5..afdb3c0570 100644 --- a/windows/tauri/src-tauri/src/lsp.rs +++ b/windows/tauri/src-tauri/src/lsp.rs @@ -10,12 +10,12 @@ mod jdt_workspace; use crate::{logging::LogManager, run}; -#[cfg(test)] -use jdt_workspace::JDT_CACHE_LAST_USED_MARKER; use jdt_workspace::{ cleanup_expired_java_index_directories, clear_java_index_directory, collect_workspace_fingerprint_inputs, now_unix_seconds, resolve_workspace_fingerprint, }; +#[cfg(test)] +use jdt_workspace::{gradle_wrapper_distribution, JDT_CACHE_LAST_USED_MARKER}; use serde::Serialize; use serde_json::json; use std::ffi::OsStr; @@ -1055,20 +1055,59 @@ mod tests { fs::remove_dir_all(workspace).ok(); } + // JDT LS updates the projects whose build files changed when it restarts, + // so an edit must keep the imported workspace instead of forcing a cold + // re-import into a new state directory. #[test] - fn workspace_fingerprint_observes_root_build_file_metadata() { + fn editing_a_root_build_file_keeps_the_workspace_observations() { let workspace = temp_dir(); let pom = workspace.join("pom.xml"); fs::write(&pom, "a").expect("initial pom"); let first = collect_workspace_fingerprint_inputs(&workspace).expect("first observations"); fs::write(&pom, "a larger project descriptor").expect("updated pom"); - let changed = - collect_workspace_fingerprint_inputs(&workspace).expect("changed observations"); + let edited = collect_workspace_fingerprint_inputs(&workspace).expect("edited observations"); assert_eq!(first.build_files.len(), 1); assert_eq!(first.build_files[0].path, "pom.xml"); - assert!(changed.build_files[0].size_bytes > first.build_files[0].size_bytes); + assert_eq!(edited, first); + fs::remove_dir_all(workspace).ok(); + } + + // JDT LS does not check the wrapper descriptor, so the Gradle version is + // what moves a workspace to a fresh state directory. + #[test] + fn the_gradle_wrapper_version_selects_the_workspace_state() { + let workspace = temp_dir(); + fs::write(workspace.join("build.gradle"), "plugins {}").expect("build file"); + assert_eq!( + gradle_wrapper_distribution(&workspace).expect("no wrapper"), + None + ); + + write_gradle_wrapper_descriptor(&workspace); + let inputs = collect_workspace_fingerprint_inputs(&workspace).expect("observations"); + assert_eq!( + inputs.gradle_wrapper_distribution.as_deref(), + Some("https://services.gradle.org/distributions/gradle-8.10-bin.zip") + ); + let first = resolve_workspace_fingerprint(inputs, "9.4.1", "test-fingerprint") + .expect("Core fingerprint"); + + fs::write( + workspace.join("gradle").join("wrapper").join("gradle-wrapper.properties"), + "# upgraded\ndistributionUrl = https\\://services.gradle.org/distributions/gradle-8.11-bin.zip\n", + ) + .expect("upgraded wrapper descriptor"); + let upgraded = collect_workspace_fingerprint_inputs(&workspace).expect("observations"); + assert_eq!( + upgraded.gradle_wrapper_distribution.as_deref(), + Some("https://services.gradle.org/distributions/gradle-8.11-bin.zip") + ); + let second = resolve_workspace_fingerprint(upgraded, "9.4.1", "test-fingerprint") + .expect("Core fingerprint"); + + assert_ne!(first, second); fs::remove_dir_all(workspace).ok(); } diff --git a/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs b/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs index af69e9e843..4df96039d4 100644 --- a/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs +++ b/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs @@ -6,37 +6,38 @@ use std::collections::BTreeSet; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; -/// Root build declarations whose metadata feeds the Java workspace fingerprint. +/// Root build declarations whose presence feeds the Java workspace fingerprint. /// -/// Gradle keeps part of a project's structure outside `build.gradle`, so the -/// included modules in `settings.gradle` and the wrapper descriptor Buildship -/// reads are observed too; otherwise adding a subproject or switching the -/// Gradle version would leave a stale JDT LS workspace directory in place. -/// `gradle.properties` is deliberately not observed: mod projects rewrite it on -/// nearly every version bump, and each edit would discard the imported workspace -/// and force a cold re-import. +/// Only presence counts: JDT LS compares each POM and Gradle script's timestamp +/// with the state it saved and updates the changed projects itself, so an edit +/// keeps the imported workspace. The wrapper descriptor is listed because +/// Buildship reads it, and its Gradle version is observed separately (see +/// [`gradle_wrapper_distribution`]). `gradle.properties` is deliberately not +/// observed: mod projects add and remove it freely. +const GRADLE_WRAPPER_DESCRIPTOR: &str = "gradle/wrapper/gradle-wrapper.properties"; const WORKSPACE_BUILD_FILES: &[&str] = &[ "pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", - "gradle/wrapper/gradle-wrapper.properties", + GRADLE_WRAPPER_DESCRIPTOR, ]; pub(super) const JDT_CACHE_LAST_USED_MARKER: &str = ".lithe-last-used"; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct JdtBuildFileObservation { pub(super) path: String, - pub(super) modified_unix_milliseconds: u64, - pub(super) size_bytes: u64, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct JdtWorkspaceFingerprintInputs { pub(super) build_files: Vec, pub(super) direct_maven_modules: Vec, + /// The wrapper's `distributionUrl`; JDT LS does not notice a Gradle version + /// change on its own, so the version selects a fresh state directory. + pub(super) gradle_wrapper_distribution: Option, } #[derive(Debug, Deserialize)] @@ -73,42 +74,16 @@ pub(super) fn collect_workspace_fingerprint_inputs( path: &Path, relative_path: &str, ) -> Result, String> { - let metadata = match std::fs::metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!( - "Failed to inspect Java build file {}: {error}", - path.display() - )) - } - }; - let modified = metadata.modified().map_err(|error| { - format!( - "Failed to read Java build-file timestamp {}: {error}", + match std::fs::metadata(path) { + Ok(_) => Ok(Some(JdtBuildFileObservation { + path: relative_path.to_string(), + })), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "Failed to inspect Java build file {}: {error}", path.display() - ) - })?; - let modified_millis = modified - .duration_since(UNIX_EPOCH) - .map_err(|error| { - format!( - "Java build-file timestamp predates the Unix epoch {}: {error}", - path.display() - ) - })? - .as_millis(); - let modified_unix_milliseconds = u64::try_from(modified_millis).map_err(|_| { - format!( - "Java build-file timestamp is out of range: {}", - path.display() - ) - })?; - Ok(Some(JdtBuildFileObservation { - path: relative_path.to_string(), - modified_unix_milliseconds, - size_bytes: metadata.len(), - })) + )), + } } let mut build_files = Vec::new(); @@ -166,9 +141,53 @@ pub(super) fn collect_workspace_fingerprint_inputs( Ok(JdtWorkspaceFingerprintInputs { build_files, direct_maven_modules, + gradle_wrapper_distribution: gradle_wrapper_distribution(workspace_root)?, }) } +/// The `distributionUrl` of the workspace's Gradle wrapper descriptor, if any. +/// +/// The descriptor is a Java properties file, whose writer escapes `:` and `=` +/// in values (`https\://services.gradle.org/...`); escapes are removed so the +/// same version always reads the same. +pub(super) fn gradle_wrapper_distribution(workspace_root: &Path) -> Result, String> { + let path = workspace_root.join(GRADLE_WRAPPER_DESCRIPTOR); + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "Failed to read Gradle wrapper descriptor {}: {error}", + path.display() + )) + } + }; + for line in contents.lines() { + let line = line.trim_start(); + if line.starts_with('#') || line.starts_with('!') { + continue; + } + let Some(value) = line + .strip_prefix("distributionUrl") + .map(str::trim_start) + .and_then(|rest| rest.strip_prefix(['=', ':'])) + else { + continue; + }; + let mut unescaped = String::with_capacity(value.len()); + let mut characters = value.trim().chars(); + while let Some(character) = characters.next() { + if character == '\\' { + unescaped.extend(characters.next()); + } else { + unescaped.push(character); + } + } + return Ok(Some(unescaped).filter(|value| !value.is_empty())); + } + Ok(None) +} + /// Delegates portable ordering and serialization of observations to Rust Core. pub(super) fn resolve_workspace_fingerprint( inputs: JdtWorkspaceFingerprintInputs, @@ -182,6 +201,7 @@ pub(super) fn resolve_workspace_fingerprint( "payload": { "buildFiles": inputs.build_files, "directMavenModules": inputs.direct_maven_modules, + "gradleWrapperDistribution": inputs.gradle_wrapper_distribution, "jdtlsVersion": jdtls_version, } }); From 615ab6b4d2bcd0db19e117536a8f521cf0188713 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 28 Oct 2026 23:24:50 -0700 Subject: [PATCH 3/3] docs(matrix): record the JDT workspace key and jdtls JVM defaults Co-Authored-By: Claude Opus 5.5 --- .../features/lsp-lifecycle.json | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/shared/platform-feature-matrix/features/lsp-lifecycle.json b/shared/platform-feature-matrix/features/lsp-lifecycle.json index 9a456644b0..42d5b715dc 100644 --- a/shared/platform-feature-matrix/features/lsp-lifecycle.json +++ b/shared/platform-feature-matrix/features/lsp-lifecycle.json @@ -22,7 +22,14 @@ "macos/Tests/LitheTests/AppModelLanguageSessionShutdownTests.swift", "macos/Tests/LitheTests/RustLanguageServerLaunchTests.swift", "docs/assets/java-startup-navigation/java-ready.png", - "docs/assets/java-startup-navigation/get-order-definition.png" + "docs/assets/java-startup-navigation/get-order-definition.png", + "rust/lithe-core/src/lsp/languages/java_workspace.rs", + "shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json", + ".agents/notes/implemented/feature/2026-10-11-gradle-project-import-settings.md", + "rust/lithe-core/src/lsp/interface/engine.rs", + "macos/Sources/Lithe/Platform/MacOS/Plugins/HostIntegrations/Jdt/MacJdtWorkspaceState.swift", + "macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift", + "macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -36,12 +43,17 @@ "shared/fixtures/lsp", "rust/lithe-core/src/lsp/languages/jdt_progress.rs", "shared/fixtures/lsp/jdt-readiness-v1.json", - ".agents/notes/implemented/bug-fix/2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md" + ".agents/notes/implemented/bug-fix/2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md", + "rust/lithe-core/src/lsp/languages/java_workspace.rs", + "shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json", + ".agents/notes/implemented/feature/2026-10-11-gradle-project-import-settings.md", + "rust/lithe-core/src/lsp/interface/engine.rs", + "windows/tauri/src-tauri/src/lsp/jdt_workspace.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Language Tooling", - "verification": "在两端启动真实 JDTLS,验证项目准备、重启、超时、取消和资源清理,并确认典型工作流后已安装程序包(macOS app bundle、Windows 安装目录)的文件清单不变。 验证 Maven 导入连续 45 秒无进度只警告、之后 ServiceReady 可正常就绪,且 10 分钟总期限、显式失败和取消仍有效。 检查 JDT LS 启动参数的最大堆高于 1.5 GB,并在待导入模块超过 50 个、且子模块 Maven 坐标重复的聚合工程中确认工作区准备不再以 serviceReadyFailed 终止整个 Java 服务。 macOS #1524:暂停旧模块图清理后请求普通语言能力、后台 Java 维护及文档激活,确认等待结束后才读取缓存或启动新会话;等待期间取消不得重新激活;匹配 operation 的停止应清除 preparing,旧 operation 的停止不得影响新准备。 macOS #1523:通过插件入口启动 Java,确认宿主集成、Maven/Gradle 与缓存参数不丢失,连续失败最多自动启动三次,显式重启可恢复,且包内文件哈希保持不变。 macOS 最终关闭场景:暂停旧模块清理、排队文档和维护激活、关闭项目会话、释放清理;即使 AppModel 保留工作区 URL 与打开文档,也不能重新发布语言能力或启动 LSP。 macOS 生产 RustCoreBridge 与本地协议进程验证插件身份、host services、配置值和 JDT 缓存启动上下文;修复后的 Java ready 与 getOrder 定义导航截图见 evidence。", - "notes": "JDT LS 统一以 -Xmx2048m 启动(#1156):打包的 1.61.0 在待导入模块超过 50 个且最大堆不超过 1.5 GB 时会走带缺陷的分批导入,重复 Maven 坐标会让 null 工程进入 IProject.open 并终止整个 Java 服务;上游修复进入下个里程碑前用这条堆上限规避。代价是语言服务 JVM 的堆上限从 1 GB 提到 2 GB,-Xms 仍为 256m。坐标完全相同的模块仍只有第一个成为 Java 工程,其余由 m2e 跳过。" + "verification": "在两端启动真实 JDTLS,验证项目准备、重启、超时、取消和资源清理,并确认典型工作流后已安装程序包(macOS app bundle、Windows 安装目录)的文件清单不变。 验证 Maven 导入连续 45 秒无进度只警告、之后 ServiceReady 可正常就绪,且 10 分钟总期限、显式失败和取消仍有效。 检查 JDT LS 启动参数的最大堆高于 1.5 GB,并在待导入模块超过 50 个、且子模块 Maven 坐标重复的聚合工程中确认工作区准备不再以 serviceReadyFailed 终止整个 Java 服务。 macOS #1524:暂停旧模块图清理后请求普通语言能力、后台 Java 维护及文档激活,确认等待结束后才读取缓存或启动新会话;等待期间取消不得重新激活;匹配 operation 的停止应清除 preparing,旧 operation 的停止不得影响新准备。 macOS #1523:通过插件入口启动 Java,确认宿主集成、Maven/Gradle 与缓存参数不丢失,连续失败最多自动启动三次,显式重启可恢复,且包内文件哈希保持不变。 macOS 最终关闭场景:暂停旧模块清理、排队文档和维护激活、关闭项目会话、释放清理;即使 AppModel 保留工作区 URL 与打开文档,也不能重新发布语言能力或启动 LSP。 macOS 生产 RustCoreBridge 与本地协议进程验证插件身份、host services、配置值和 JDT 缓存启动上下文;修复后的 Java ready 与 getOrder 定义导航截图见 evidence。 PR #1573:在两端打开已导入的 Maven 工程,修改 pom.xml(如增加依赖)后确认 JDT -data 目录不变、jdtls 增量更新而不是重新导入整个工作区;Windows 修改 gradle-wrapper.properties 的 distributionUrl 后确认切换到新的 -data 目录。检查 JDT LS 进程参数包含 -XX:+UseParallelGC、-XX:GCTimeRatio=4、-XX:AdaptiveSizePolicyWeight=90、-Xlog:disable 与 -DDetectVMInstallationsJob.disabled=true,导入完成后常驻内存回落。macOS 在大型工程导入期间打字、滚动与补全不掉帧(事件 drain 已移出主线程)。", + "notes": "JDT LS 统一以 -Xmx2048m 启动(#1156):打包的 1.61.0 在待导入模块超过 50 个且最大堆不超过 1.5 GB 时会走带缺陷的分批导入,重复 Maven 坐标会让 null 工程进入 IProject.open 并终止整个 Java 服务;上游修复进入下个里程碑前用这条堆上限规避。代价是语言服务 JVM 的堆上限从 1 GB 提到 2 GB,-Xms 仍为 256m。坐标完全相同的模块仍只有第一个成为 Java 工程,其余由 m2e 跳过。 JDT -data 工作区 key 只按构建文件是否存在、直接 Maven 模块、JDT LS 版本(以及 Windows 的 Gradle wrapper distributionUrl)计算,不再包含修改时间与大小:jdtls 自己检测 pom 与 Gradle 脚本的修改并增量更新。升级到该版本后每个 Java 工程首次打开会重建一次索引。JDT LS 额外以 vscode-java 与上游 CI 的 GC/日志默认值启动,插件同键参数优先。" }