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 59ba30dcd7..db534982f8 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -981,7 +981,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."; @@ -5473,7 +5472,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 840a9eef8b..1600a18859 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -981,7 +981,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." = "无法读取所选的内置背景。"; @@ -5473,7 +5472,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/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/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/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/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/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(), }, ); 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 085801cc55..2baba5dbde 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -2654,16 +2654,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 @@ -2681,9 +2688,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 d6e7daa6ce..b406e05435 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9051,7 +9051,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 4d980105d9..a22b4da9d4 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9051,7 +9051,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 d8510bf2db..c1d109ef69 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -84217,22 +84217,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/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/日志默认值启动,插件同键参数优先。" } diff --git a/windows/tauri/src-tauri/src/lsp.rs b/windows/tauri/src-tauri/src/lsp.rs index 40597fe27c..e001c28068 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::{blocking::run_blocking, 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; @@ -1070,20 +1070,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, } });