From 831bc21181bccef40b4905e4c63ab5db32ef305f Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Fri, 13 Nov 2026 14:34:19 -0800 Subject: [PATCH] fix(java): stabilize Windows launches and cold-index run actions --- Cargo.lock | 53 ++ README.md | 153 +++--- backend/Cargo.toml | 4 + backend/src/launch.rs | 88 ++- backend/src/lib.rs | 5 +- backend/src/run.rs | 517 +++++++++++++++++- backend/src/settings_session.rs | 2 +- crates/java-domain/Cargo.toml | 6 + .../src/execution/classpath_jar.rs | 20 +- .../src/jdt_integration/workspace_state.rs | 18 +- .../src/languages/code_vision_hints.rs | 2 +- crates/java-domain/src/lsp/languages/jdt.rs | 11 + io.github.lithe-idea.java-support/plugin.json | 2 + scripts/package/build-windows.mjs | 92 ++++ 14 files changed, 837 insertions(+), 136 deletions(-) create mode 100644 scripts/package/build-windows.mjs diff --git a/Cargo.lock b/Cargo.lock index 458e7f4..b610d42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,12 +129,28 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "equivalent" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -302,6 +318,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "junction" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde48f36530cbd91b65f050101e6c056d6eb743cea2a8f0f19beef69c6742ff8" +dependencies = [ + "scopeguard", + "windows-sys", +] + [[package]] name = "libc" version = "0.2.191" @@ -318,15 +344,21 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" name = "lithe-java-backend" version = "0.1.0" dependencies = [ + "base64", + "dunce", "lithe-java-domain", "lithe-plugin-sdk", "serde_json", + "url", + "zip", ] [[package]] name = "lithe-java-domain" version = "0.1.0" dependencies = [ + "filetime", + "junction", "regex", "serde", "serde_json", @@ -430,6 +462,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -623,6 +661,21 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/README.md b/README.md index 3b5fadb..5f8fecb 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,9 @@ > SDK. Maven and Gradle import settings come from the Maven and Gradle plugins through a function interface that > Lithe Core relays between plugins. -> **进行中**:本仓库正在从 Lithe 主仓库拆分出来,目标是 Lithe 宿主(Rust Core 和 macOS 应用)不再包含任何 Java 专属逻辑, -> Java 的领域规则全部由本插件提供,宿主只提供通用插件接口。插件现在带一个 Rust 后端:`plugin.json` 声明了 `backend` 2, -> 后端承担 Maven/Gradle 导入设置的中转(见“工作方式”),并解析语言服务器的启动:`plugin.json` 的 `languageServers[0].launch` -> 是 `kind: "backend"`、函数 `java.resolveLaunch`(`serverProcess` 3),不再有 `hostIntegration`;后端在 `backend/src/launch.rs` -> 里定位包内的 Equinox 启动器、按架构的配置目录、Lombok 代理、java-debug 与 java-test 扩展包和随包 JDK,拼出 JDT LS 的命令行、 -> 初始化选项和 `language/status` 就绪门。由插件接管运行/调试/测试还在进行,清单见 `MIGRATION.md` 第 6 节和第 10 节。 -> 在宿主去掉内置 Java Support(旧 ID `dev.lithe.plugin.java-support`)之前,本仓库发布的版本标为预发布,原因见 `MIGRATION.md` 第 9 节。 -> Maven Archetype 生成器、Maven 与 Gradle 的项目模板已经移交 lithe-plugin-maven 和 lithe-plugin-gradle; -> 在这两个插件接手模板之前不要发布本插件的新版本,否则“新建项目”会少掉 Maven 和 Gradle 选项。 +当前源码面向配套 Viva preview,尚未发布。API / 能力与传输版本以 `plugin.json`、`sdk.lock.json` +和固定 Core 中的[插件资料](https://github.com/Lithe-IDEA/Lithe-core-rust/tree/65cf82343c5e315aae2e335b2e8f0217b703e89a/shared/plugins)为准。 +`MIGRATION.md` 保留迁移历史,不能用其中旧宿主、旧 API 数字推断当前兼容性。 ## 当前 Viva 验证入口 @@ -54,11 +48,55 @@ scripts/desktop/desktop-go.sh test ./internal/workbench \ 高频动作收集没有成功 INFO。Debug Stop 同时回收 Run 持有的 JVM。 本轮未验收原生日志窗口、JDT/适配器启动失败或启动中取消,以及所有降级/恢复分支; -上述集成结果不能替代这些日志验收。生成器、JUnit/TestNG、Spring/MyBatis、Gradle 也未在本轮执行。 +上述集成结果不能替代这些日志验收。生成器、JUnit/TestNG 行内运行器、Spring/MyBatis 未在本轮执行;Gradle 扩展结果见下表。 -当前仅在 macOS arm64 的无头 Viva 工作台验证,不能视为原生窗口、Windows/Linux、任意复杂依赖、 -私有仓库或完整传统 Java EE 验收。Windows 包和长 classpath 尾项仍见 -[MIGRATION.md](MIGRATION.md) 与 [#7](https://github.com/Lithe-IDEA/lithe-plugin-java/issues/7)。 +无头工作台结果不能视为原生窗口、Linux、任意复杂依赖、私有仓库或完整传统 Java EE 验收。 +Windows 长 classpath 修复只覆盖 classpath,长 module-path 仍见 [#7](https://github.com/Lithe-IDEA/lithe-plugin-java/issues/7)。 + +### 本轮扩展验证 + +macOS arm64 的真实插件与 Viva 无头工作台已覆盖: + +| 流程 | 结果与边界 | +| --- | --- | +| 普通 Maven + 第三方 JAR | 编译、补全、运行、断点变量、继续/停止、编译错误阻止旧 class | +| Gradle 7.6.4 Wrapper,两模块,中文空格目录 | Build 模型、compileJava、JDT 补全和同一 Run/Debug 流程;项目使用 Java 8 | +| 普通源码、外部 JAR、Eclipse 多源码根、Ant 目录 | JDT 编译后核对 major 52,并在 Java 8 实际运行/调试;没有执行 Ant target | +| Maven WAR / Tomcat 9.0.82 | 真实 JSP scriptlet、EL、JSTL、include HTTP 响应,以及 Servlet 断点/变量;未验收 JSP 源码补全或 JSP 断点 | +| Commons Text 1.9,`cb85bed468e99d34b88d0c81fe20eb3b1615660e` | 原 POM 不变,加入带许可证的 API 验证入口,Maven/补全/运行/断点/继续停止通过,36.25 秒 | + +Windows 验证结果随原生报告另行记录,不能由上述 macOS 结果推断。ARM Windows 虚拟机上的 x64 工具经过系统仿真,耗时不能当成原生 AMD64 性能。 +原生窗口、导航/虚拟源码、JUnit/TestNG 行内运行器、Spring/MyBatis、日志窗口、启动中取消和每一种失败恢复仍未完整验收。 + +当前真实旧项目限制: + +- Lanterna 3.1.1 原多版本 JAR 配置:项目 JDK 11 下 Maven/补全通过,JDT 要求启用 release,运行前构建失败,见 [#1882](https://github.com/1lck/Lithe-IDEA/issues/1882)。 +- JSQLParser 3.0:Maven 已生成 JavaCC 源码,JDT 模型仍缺相关类型,Run/Debug 未通过,见 [#1881](https://github.com/1lck/Lithe-IDEA/issues/1881)。 +- jOOL 0.9.14 的 Java 8 子项目:Maven compile/补全通过,ECJ 的泛型捕获转换报错,不能算完整支持,见 [#1883](https://github.com/1lck/Lithe-IDEA/issues/1883)。 + +固定同一 Viva 测试二进制、Core、Java 17 和工程,关闭虚拟机,按 AB/BA 交错运行六对原始/最终真实 ZIP:12 轮全部通过。 +中位数如下;Maven 依赖与系统文件缓存保留,每轮重新安装插件并新建工作区。 + +| 指标 | 原始 ZIP | 最终 ZIP | benchstat | +| --- | ---: | ---: | --- | +| 打开 Java 文件到动作可用 | 6.493 s | 6.421 s | 无显著差异,p=0.394 | +| 首次补全 | 75.98 ms | 74.46 ms | 无显著差异,p=0.699 | +| 热补全(每轮 12 次的中位数) | 11.76 ms | 11.87 ms | 无显著差异,p=1.000 | +| JDT 就绪后 RSS | 793.25 MiB | 793.79 MiB | 无显著差异,p=0.589 | +| JDT 流程结束 RSS | 816.13 MiB | 817.40 MiB | 无显著差异,p=0.699 | + +Windows x64 在 ARM Windows 11 虚拟机中验证:Maven 64.77 秒、Gradle 104.91 秒(含真实配置回读且中转无超时); +其他同一修复链路的原生结果含普通源码 52.80 秒、外部 JAR 49.46 秒、Ant 布局 55.70 秒、Eclipse 多源码根 47.11 秒、 +长 classpath 48.70 秒、JSP/Servlet 74.70 秒。它们覆盖同一补全/运行/调试/停止/拒绝旧 class 流程,安装包哈希不变。 +Windows 的三个通用工具入口单元回归与 Java 8 配置后端测试也通过。虚拟机已关闭。 +这些结果不代表原生窗口、安装器、签名、ARM64 发布包或持续压力测试验收。 + +Java 工具定义的 JAR 入口模板由 Core 的既有工具规划接口实例化;宿主先验证包内入口路径。 +Gradle 后端只提供自己的堆选项,`-jar` 与入口位置来自 Java 插件的工具定义,裸 JVM 参数的 Run/JDT 启动保持原有流程。 +配套 Viva 必须包含工具入口接线;只有构建任务成功不能证明配置后端正常,真实回读与中转检查不可省略。 + +GC 和初始堆的隔离试验没有获得可靠收益,因此保留 Parallel GC、Xms256m / Xmx2048m;没有提交试验用 G1/Serial/ZGC 或 JDT 1.63 / Java 25 包。 +内存报告中的 JDT 进程树 RSS 是观测时点,不是峰值、PSS 或原生客户端总内存,不能把测试驱动内存变化说成产品内存下降。 ### 旧项目兼容性边界 @@ -131,31 +169,18 @@ Spring/MyBatis 等既有能力不属于本轮 Maven 验收范围。 ## 工作方式 -- **领域规则**在 `crates/java-domain`,不依赖宿主:命令的 JSON 载荷和错误码与主仓库 Core 一一对应。 - 所有宿主差异(存储、Git、语言服务器通道)都是带 `TODO(host-api: API-NN)` 的占位;日志已接入宿主统一日志,见“日志”一节。 -- **插件后端**在 `backend/`:基于固定版本的 Rust SDK(`sdk.lock.json`),把每条领域命令注册为同名插件函数, - 并带着跨插件函数的调用方与导出方两侧。`plugin.json` 用 `backend.launch`(`kind: "packaged"`,路径 `bin/lithe-java-backend`)声明它, - 打包脚本把按架构编译好的二进制放进包的 `bin/`。后端是全机一个进程,所有工作区共享。 -- **设置中转**(Maven/Gradle 导入设置到 JDT LS): - - 调用方向:宿主通知 `lithe/host/languageServer/didChangeState` 说某个工作区的 Java 语言服务器进入 `running` 后,后端通过 Core 中转发现所有导出 - `languageImport.settings` 的插件(Maven、Gradle),逐个调用,只接受落在 JDT LS 导入设置命名空间里的键,合并后用 - `languageServer/notify` 向语言服务器推送 `workspace/didChangeConfiguration`。语言服务器重启(代号变化)后会重新收集。 - - 导出方向:Maven/Gradle 设置变化时调用 Java 导出的 `languageImport.settingsChanged`(`context: "workspace"`)。后端按 Core 写入的调用方插件 ID - **整份替换**该调用方自己的槽位,不会影响别的提供方;调用里可以附带 `projects`(`{projectUri, selectedProfiles}`), - 后端据此向 JDT LS 发 `java.project.updateSettings`,应用 Maven profiles。Java 不会反过来调用正在调用它的插件,否则调用链会被 Core 当作环路拒绝。 - - 过渡规则:没有任何提供方时后端什么都不推送,主仓库 jdt 宿主集成推送的设置保持生效;后端的整份替换只在至少有一个提供方参与后才发生。 - - 清单声明:`requires.backend: 2`、`pluginFunctions: 1`、`languageServers: 3`;`pluginFunctions.calls` 为 `languageImport.settings`, - `exports` 为 `languageImport.settingsChanged`;权限 `backend.launch`、`languageServer.request`、`workspace.metadata`; - 语言服务器声明 `requestMethods: ["workspace/executeCommand"]` 和 `commands: ["java.project.updateSettings"]`;激活事件 `onWorkspaceOpen`, - 这样打开工作区时后端就会启动,Maven/Gradle 才能随时调用它。 - - 当前不推送 `java.configuration.runtimes`:插件后端还没有列出 JDK 安装的宿主方法,等 API-23(`toolchains.list`)提供。JDT LS 不会因为缺这个键而删除已登记的 VM。 - - 细节见 `MIGRATION.md` 第 5 节。 -- **运行/调试/测试**在 `backend/src/run.rs`:用宿主的编辑器动作(`editor/registerActions`)提供行内 Run/Debug 标记, - 用 `run/start` 启动进程(进程用清单 `requiresTools: ["java"]` 引用的 JDK 工具定义),测试结果由后端自己在回环端口上监听 - JUnit 运行器并用窗口消息显示,输出链接匹配器把堆栈行解析成工作区文件。细节见 `MIGRATION.md` 第 5.9 节。 -- **运行时只读**:插件包安装后在运行时只读。JDT LS、Lombok、java-debug、测试运行器、JDK 和后端二进制都在构建阶段固定来源、校验哈希(或由锁定的源码编译)后放进包里, - 运行时状态只写宿主提供的状态目录,不写包目录。 +- Java 专属的 URI、JDT 初始化、编译/运行/调试和长 classpath 规划属于本插件及成熟上游;宿主只提供通用服务。 + Maven / Gradle 自己解析配置,通过 `languageImport.settings` 接口提供 JDT 导入设置,Java 不维护第二套构建模型。 +- `backend/src/launch.rs` 定位已打包的 JDT、Lombok、java-debug、java-test 和运行语言服务器的 JDK。 + 宿主传入用户所选项目 JDK 的安装事实,插件据此设置 JDT 运行时;随包 JDK 不代替项目的目标版本。 +- 初始化就绪不等于索引完成。JDT 公开的 `progressReportProvider` 扩展将完成事件发到已声明的 + `language/progressReport` 通道。只有动作查询发生瞬态失败才在完成边界刷新;同时处理完成与超时倒序, + 每个失败周期最多自动刷新三次,成功重置预算,工作区/服务器代际隔离旧结果,关闭后删除状态。不增加空闲轮询。 +- Windows 使用原生 `java.exe`,文件 URI 由 `url` crate 处理,包路径通过 `dunce` 去除可安全简化的 verbatim 前缀, + 避免 Lombok 的 `-javaagent` 加载失败。过长 classpath 复用领域层的 manifest JAR 规划,适用于 Java 8 及更新版本; + ASCII 百分号编码覆盖中文/空格,宿主写入临时 JAR 并在运行结束删除。长 module-path 尚未覆盖。 +- 已安装插件包始终只读。JDT 配置副本、索引、日志、锁和临时文件写到宿主提供的用户状态/缓存/临时目录; + 插件包、app bundle 和 Windows 安装目录不作为可写目标。构建期固定来源、校验资源哈希,运行期不下载。 ## 日志 @@ -176,8 +201,9 @@ Spring/MyBatis 等既有能力不属于本轮 Maven 验收范围。 | 列出 `languageImport.settings` 提供方失败、单个提供方失败或超时、答复不可用、答复含越界键、提供方导出的函数版本不符 | WARN(`degraded`) | `backend/src/plugin_functions.rs` | 类别 `relay.providers_unavailable`、`relay.provider_failed`、`relay.provider_timed_out`、`relay.settings_rejected`、`relay.keys_rejected`、`relay.provider_version_mismatch`,计数;不含提供方 ID | | 取不到工作区根目录、设置推给 JDT LS 失败、单个工程的 profiles 更新失败、语言服务器状态通知格式不对 | WARN(`degraded`) | `backend/src/settings_session.rs` | 类别 `session.root_unavailable`、`session.push_failed`、`session.project_update_failed`、`session.state_invalid`,计数;操作 ID 为 `notification`(状态通知触发)或 `languageImport.settingsChanged` | | 插件存储清理失败 | WARN(`degraded`) | `crates/java-domain/src/host/diagnostics.rs`(`host::diagnostics`) | 类别 `storage.cleanup_failed.<阶段>`(阶段为 `jdt-configuration`、`jdt-settings`、`java-argfile-remove`),计数 | -| 运行动作失败 | ERROR | SDK 自动 | `backend/src/run.rs` 的 `RUN_ERROR_CODES`:`run.invalid_params`、`run.context_missing`、`run.workspace_unavailable`、`run.language_server_failed`、`run.build_failed`、`run.no_tests`、`run.port_unavailable`、`run.host_rejected` | +| 运行动作失败 | ERROR | SDK 自动 | `backend/src/run.rs` 的 `RUN_ERROR_CODES`:`run.invalid_params`、`run.context_missing`、`run.workspace_unavailable`、`run.language_server_failed`、`run.build_failed`、`run.no_tests`、`run.port_unavailable`、`run.host_rejected`、`run.classpath_failed` | | 动作注册、上下文读取或测试结果摘要降级 | WARN | SDK `degraded` | `RUN_CATEGORIES`:`run.actions_registration_failed`、`run.contexts_unavailable`、`run.result_summary_failed` | +| 冷索引动作查询延迟/恢复、自动刷新耗尽 | WARN | SDK `degraded` / `recovered` | `run.actions_query_deferred`、`run.actions_query_exhausted`、`run.actions_progress_limit`,每个失败周期记录延迟及恢复/耗尽,不含工作区内容 | | 宿主启动 JDT LS、java-debug、项目 JVM | 宿主决定 | 声明式贡献/Run 生命周期 | 插件不重复发送启动终态;上游 stderr 由宿主按 DEBUG 接收 | | JDT LS 自身日志 | 上游,默认 WARNING | `plugin.json` 的 `-Dosgi.logfile=${logDir}/jdt.log` | 只写宿主提供的会话日志目录,单文件 1 MB、保留 2 个备份;用户可在设置里调 `logLevel` | @@ -187,16 +213,10 @@ Rust 后端的通道是 `plugins/io.github.lithe-idea.java-support/host`;JDT L ## 安装 -Lithe 不再区分官方插件和普通插件,所有插件都按普通插件处理:本插件不带发布者签名(`plugin.json` 的 `vendor.signatureRequirement` 为 `none`)。 -区别只在 [Lithe Store](https://github.com/Lithe-IDEA/Lithe-store) 怎样收录:Lithe-IDEA 组织里 `lithe-plugin-*` 仓库的稳定版(非预发布) -GitHub Release 由商店机器人自动跟踪并上架;组织之外的插件向 Lithe-store 提交 pull request,维护者审核后上架。 - -目前本仓库只发布预发布版,商店机器人会跳过,所以还不能从 Lithe Store 安装。当前版本的 Lithe 也会拒绝或无法完整运行这个包: -本包声明了 `backend` 2、`languageServers` 3(插件自有语言服务器会话)和 `pluginFunctions` 1,宿主要包含主仓库的 -[#1745](https://github.com/1lck/Lithe-IDEA/pull/1745)、[#1746](https://github.com/1lck/Lithe-IDEA/pull/1746) 之后的预览版才提供它们,稳定版还没有; -此外 Lithe 仍然内置旧 ID `dev.lithe.plugin.java-support` 的 Java Support。 -(普通插件使用 JDT 宿主集成和短工具 ID `java` 的限制已由主仓库 [#1763](https://github.com/1lck/Lithe-IDEA/pull/1763) 取消。) -具体原因和转为正式发布的条件见 `MIGRATION.md` 第 9 节。下载的 ZIP 通过 **设置 → 插件** 的 ⚙ 菜单 → **从磁盘安装插件…** 安装。 +本分支需要包含配套接线修复的 Viva preview。使用真实 ZIP 从客户端的插件安装入口安装, +同时安装兼容当前契约的 Maven / Gradle 插件。本轮使用 Maven 已发布的 `v0.0.1` 预发布包, +Gradle 使用未发布的 `1.0.0` 开发包;不能用旧稳定宿主的安装要求推断兼容性。 +目前没有发布新 Release、tag 或更新市场条目。 ## 开发 @@ -224,7 +244,7 @@ cargo test --workspace --locked scripts/verify-sdk-lock.sh ``` -所有测试都是有界、确定性的:不联网,不读全局时间,不占固定端口。 +Rust 单元测试使用有界资源;真实 Viva 集成测试需要显式 ZIP、JDK、构建工具输入,外部依赖首次解析可能联网,不属于离线单元测试。 构建并归档插件包(会下载并校验 JDT LS 与 JDK,体积较大)。本地开发、CI 和发布使用同一组命令,产出同一种归档: @@ -238,27 +258,24 @@ scripts/archive-package.sh --package <上一步打印的目录> --arch arm64 # 包里不能有 `lithe-plugin-signature.json`,`bin/` 里只能有 `backend.launch.path` 指向的后端二进制,它必须可执行且包含请求的架构(`lipo -archs`), 并检查打包的 JDK 保留着上游的 Apple 代码签名。归档不需要签名密钥,也不调用任何签名工具。 -`plugin.json` 的完整格式见 lithe-plugin-docs 里的 -[插件契约](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/contract/v1/README.md)。 +`plugin.json` 的完整格式只在固定 Core 的 `shared/plugins/contract/v1/`,不在本仓库维护副本。 + +### Windows 开发包 + +在 Windows 从同一源码以 `--locked` 编译 x64 后端后,可在 macOS 组装用于原生验证的包: + +```sh +node scripts/package/build-windows.mjs /path/to/lithe-java-backend.exe +``` + +脚本验证 x64 PE 与锁定 JDK 的 SHA-256,复用 JDT 构建入口,写入 `.artifacts/` 并清理组装临时目录。 +这是开发验证入口,不是 Windows 正式签名、安装器或发布工作流,也不能代替 Windows 运行验收。 ## 发布新版本 -1. 修改 `io.github.lithe-idea.java-support/plugin.json` 的 `version`,合入 `main`。 -2. 推送同名标签(带 `v` 前缀,必须等于清单版本): - - ```bash - git tag v1.2.0 && git push origin v1.2.0 - ``` - -3. `Release` 工作流先在 Ubuntu 上跑 Rust 检查,再在 macOS 上构建、校验并归档 arm64 与 x86_64 两个包,然后发布。 - 发布资产是 `Lithe-Java-Support-<版本>-darwin-{arm64,x64}.zip` 和对应的 `.sha256`。没有任何步骤读取签名密钥,包也不带发布者签名。 -4. 发布保持为预发布(`--prerelease`):Lithe Store 的自动跟踪会跳过预发布,原因有三个。一是目前的 Lithe 仍然内置旧 ID 的 Java Support; - 二是包声明了 `backend` 2、`languageServers` 3、`pluginFunctions` 1,后端链接的 Rust SDK 0.3.0(插件函数协议 4)还要求宿主提供 `litheLogging` 1, - 需要包含主仓库 #1745、#1746 的预览版,稳定版还没有; - 三是 Maven 与 Gradle 的新建项目模板已经移交 lithe-plugin-maven 和 lithe-plugin-gradle,这两个插件接手之前发布新版本, - “新建项目”会少掉 Maven 和 Gradle 选项,所以**在它们接手模板之前不要发布本插件的新版本**。 - 等 Lithe 主仓库的阶段 E 完成(`MIGRATION.md` 第 8.2 节)、Lithe 稳定版提供上述接口、两个构建插件发布后,去掉 `--prerelease`, - 商店就会自动上架稳定版。细节见 `MIGRATION.md` 第 9 节。 +构建和发布由现有工作流执行,正式发布前按 Lithe 的版本规则决定版本并核验兼容宿主。 +当前依赖 Viva preview,只能作为预发布;Windows 开发包尚未接入发布工作流。 +不得覆盖已发布 ZIP,也不得将本地隔离试验的 JDT/JDK 资源当作生产升级:本轮生产仍固定 JDT LS 1.61 和 Temurin 21。 ## 许可 diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5ca6480..8a9df07 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -16,3 +16,7 @@ lithe-java-domain = { path = "../crates/java-domain" } # has no release tag yet; sdk.lock.json and Cargo.lock must name the same commit. lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/Lithe-core-rust", rev = "65cf82343c5e315aae2e335b2e8f0217b703e89a" } serde_json.workspace = true +base64 = "0.22.1" +zip.workspace = true +url.workspace = true +dunce = "1.0.5" diff --git a/backend/src/launch.rs b/backend/src/launch.rs index 8c89f19..0b3b0e3 100644 --- a/backend/src/launch.rs +++ b/backend/src/launch.rs @@ -320,17 +320,24 @@ pub fn package_root() -> Result { let executable = std::env::current_exe() .and_then(fs::canonicalize) .map_err(|_| unavailable())?; - executable + package_root_from_executable(&executable).ok_or_else(unavailable) +} + +// Java instrumentation cannot load -javaagent through a Windows verbatim +// prefix. Simplify only when an ordinary spelling has the same identity; +// canonicalization above still owns symlink resolution. +fn package_root_from_executable(executable: &Path) -> Option { + dunce::simplified(executable) .parent() .and_then(Path::parent) .map(Path::to_path_buf) - .ok_or_else(unavailable) } /// The package path of the Java runtime of `architecture`, as the answer's /// `executable.path` names it. pub fn runtime_executable(architecture: &str) -> String { - format!("{RUNTIME_DIRECTORY}/{architecture}/bin/java") + let executable = if cfg!(windows) { "java.exe" } else { "java" }; + format!("{RUNTIME_DIRECTORY}/{architecture}/bin/{executable}") } /// Finds the JDT LS files of `architecture` in the package at `package_root`. @@ -544,7 +551,7 @@ fn workspace_folders(host: &Host, workspace: &WorkspaceContext) -> Result Vec { +pub fn trigger_files(folders: &[PathBuf]) -> Result, PluginError> { let mut files = Vec::new(); let mut budget = TRIGGER_SCAN_ENTRY_BUDGET; let mut roots: Vec<&PathBuf> = folders.iter().collect(); @@ -563,7 +570,7 @@ pub fn trigger_files(folders: &[PathBuf]) -> Vec { let mut subdirectories = Vec::new(); for entry in entries { if budget == 0 { - return files; + return Ok(files); } budget -= 1; let name = entry.file_name(); @@ -580,16 +587,16 @@ pub fn trigger_files(folders: &[PathBuf]) -> Vec { subdirectories.push((entry.path(), depth + 1)); } } else if kind.is_file() && name.ends_with(".java") && !name.starts_with('.') { - files.push(file_uri(&entry.path())); + files.push(file_uri(&entry.path())?); if files.len() >= TRIGGER_FILE_LIMIT { - return files; + return Ok(files); } } } pending.extend(subdirectories); } } - files + Ok(files) } /// Seconds since the Unix epoch; a clock before 1970 counts as 0, which only @@ -638,7 +645,7 @@ pub fn resolve_launch_function( } None => (Vec::new(), Vec::new()), }; - let trigger_files = trigger_files(&folders); + let trigger_files = trigger_files(&folders)?; context.check_cancelled()?; let answer = resolve( &request, @@ -655,6 +662,23 @@ pub fn resolve_launch_function( #[cfg(test)] mod tests { use super::*; + + #[test] + #[cfg(windows)] + fn package_paths_do_not_break_java_agent_loading() { + // Native Windows JDK 21 exits before initialize when -javaagent uses + // canonicalize's verbatim prefix, even for a normal local package. + let root = + package_root_from_executable(Path::new(r"\\?\C:\plugins\Java 支持\bin\backend.exe")) + .unwrap(); + assert_eq!(root, Path::new(r"C:\plugins\Java 支持")); + } + + #[test] + #[cfg(windows)] + fn windows_runtime_uses_the_packaged_pe_launcher() { + assert_eq!(runtime_executable("x86_64"), "runtime/x86_64/bin/java.exe"); + } use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; @@ -687,8 +711,9 @@ mod tests { ] { fs::create_dir_all(package.join(directory)).unwrap(); } + let runtime = runtime_executable("arm64"); for file in [ - "runtime/arm64/bin/java", + runtime.as_str(), LAUNCHER, "language-server/plugins/org.eclipse.jdt.ls.core_1.50.0.jar", "language-server/lombok/lombok.jar", @@ -810,7 +835,7 @@ mod tests { assert!(reports.is_empty()); assert_eq!( answer["executable"], - json!({"kind": "packaged", "path": "runtime/arm64/bin/java"}) + json!({"kind": "packaged", "path": runtime_executable("arm64")}) ); let arguments = strings(&answer["arguments"]); let package = fixture.package(); @@ -852,7 +877,7 @@ mod tests { .iter() .position(|argument| argument == "-jar") .unwrap(); - assert_eq!(arguments[jar + 1], launcher); + assert_eq!(Path::new(&arguments[jar + 1]), Path::new(&launcher)); assert_eq!(arguments[jar + 2], "-configuration"); let configuration = PathBuf::from(&arguments[jar + 3]); assert!(configuration.starts_with(fixture.root.join("state"))); @@ -873,16 +898,19 @@ mod tests { let fixture = Fixture::new(); let (answer, _) = resolve_fixture(&fixture, &[]); let package = fixture.package(); - let expected: Vec = [ + let expected: Vec = [ "language-server/java-debug/com.microsoft.java.debug.plugin-0.53.0.jar", "language-server/java-test/extensions/a.jar", "language-server/java-test/extensions/b.jar", ] .iter() - .map(|bundle| package.join(bundle).to_string_lossy().into_owned()) + .map(|bundle| package.join(bundle)) .collect(); assert_eq!( - strings(&answer["initializationOptions"]["bundles"]), + strings(&answer["initializationOptions"]["bundles"]) + .into_iter() + .map(PathBuf::from) + .collect::>(), expected ); assert_eq!( @@ -908,15 +936,13 @@ mod tests { let (answer, _) = resolve_fixture(&fixture, &[]); let writable = strings(&answer["writablePaths"]); assert_eq!(writable.len(), 4); - let hosts: Vec = ["state", "workspace-state", "log", "tmp"] + let hosts: Vec = ["state", "workspace-state", "log", "tmp"] .iter() - .map(|directory| fixture.root.join(directory).to_string_lossy().into_owned()) + .map(|directory| fixture.root.join(directory)) .collect(); for path in &writable { assert!( - hosts - .iter() - .any(|host| path == host || path.starts_with(&format!("{host}/"))), + hosts.iter().any(|host| Path::new(path).starts_with(host)), "{path}" ); } @@ -928,7 +954,7 @@ mod tests { .unwrap(); assert!(writable .iter() - .any(|path| configuration.starts_with(path) && path.starts_with(&hosts[0]))); + .any(|path| configuration.starts_with(path) && Path::new(path).starts_with(&hosts[0]))); } #[test] @@ -1000,8 +1026,9 @@ mod tests { #[test] fn a_damaged_package_names_the_missing_file() { + let runtime = runtime_executable("arm64"); for (remove, expected) in [ - ("runtime/arm64/bin/java", "the Java runtime"), + (runtime.as_str(), "the Java runtime"), (LAUNCHER, "the JDT LS launcher"), ("language-server/lombok/lombok.jar", "the Lombok agent"), ] { @@ -1073,16 +1100,18 @@ mod tests { ] { fs::write(workspace.join(file), b"").unwrap(); } - let files = trigger_files(&[workspace.clone(), workspace.clone()]); + let files = trigger_files(&[workspace.clone(), workspace.clone()]).unwrap(); assert_eq!( files, vec![ - file_uri(&workspace.join("lib/Util.java")), - file_uri(&workspace.join("src/demo/Main.java")), + file_uri(&workspace.join("lib/Util.java")).unwrap(), + file_uri(&workspace.join("src/demo/Main.java")).unwrap(), ] ); - assert!(trigger_files(&[fixture.root.join("missing")]).is_empty()); - assert!(trigger_files(&[]).is_empty()); + assert!(trigger_files(&[fixture.root.join("missing")]) + .unwrap() + .is_empty()); + assert!(trigger_files(&[]).unwrap().is_empty()); } #[test] @@ -1093,7 +1122,10 @@ mod tests { for index in 0..(TRIGGER_FILE_LIMIT + 5) { fs::write(workspace.join(format!("C{index:03}.java")), b"").unwrap(); } - assert_eq!(trigger_files(&[workspace]).len(), TRIGGER_FILE_LIMIT); + assert_eq!( + trigger_files(&[workspace]).unwrap().len(), + TRIGGER_FILE_LIMIT + ); } #[test] diff --git a/backend/src/lib.rs b/backend/src/lib.rs index dee849b..92df99c 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -198,7 +198,10 @@ pub fn backend() -> Result { Ok(()) } })?; - backend.function(COLLECT_FUNCTION_ID, run::collect_actions_function)?; + backend.function(COLLECT_FUNCTION_ID, { + let run_session = Arc::clone(&run_session); + move |params, context| run::collect_actions_function(&run_session, params, context) + })?; backend.suppress_success_diagnostics(COLLECT_FUNCTION_ID)?; backend.function(RUN_ACTION_FUNCTION_ID, { let run_session = Arc::clone(&run_session); diff --git a/backend/src/run.rs b/backend/src/run.rs index 54650ba..dbe7f68 100644 --- a/backend/src/run.rs +++ b/backend/src/run.rs @@ -28,15 +28,18 @@ //! //! # Not yet covered //! -//! - Long command lines are passed as they are. The domain's argfile planning -//! (`execution::launch_command`) is not applied, because macOS accepts the -//! lengths JDT LS produces for ordinary projects. +//! - Oversized Windows classpaths use a host-owned manifest JAR. Long module +//! paths and other oversized arguments still need separate planning. //! - The result summary is English text until plugin backends can reach the //! host's i18n service (API-37). use crate::launch::{self, TEST_RUNNER}; use crate::settings_session::{SettingsSession, EXECUTE_COMMAND, REQUEST_METHOD, SERVER_ID}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use lithe_java_domain::debug::java_test_launch; +use lithe_java_domain::execution::{ + plan_manifest_classpath_launch, ClasspathJarPlan, ClasspathJarRequest, ClasspathStyle, +}; use lithe_java_domain::lsp::languages::java_main_methods::{ java_main_methods_command, normalize_java_main_methods, }; @@ -54,7 +57,7 @@ use lithe_plugin_sdk::{ }; use serde_json::{json, Map}; use std::collections::{HashMap, HashSet}; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Cursor, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -159,6 +162,9 @@ pub const RUN_PORT_UNAVAILABLE: &str = "run.port_unavailable"; /// Plugin error code of a launch the host refused to start. pub const RUN_HOST_REJECTED: &str = "run.host_rejected"; +/// Preparing the host-owned manifest JAR failed before a process was started. +pub const RUN_CLASSPATH_FAILED: &str = "run.classpath_failed"; + /// Every plugin error code the run functions answer with. pub const RUN_ERROR_CODES: &[&str] = &[ RUN_INVALID_PARAMS, @@ -169,6 +175,7 @@ pub const RUN_ERROR_CODES: &[&str] = &[ RUN_NO_TESTS, RUN_PORT_UNAVAILABLE, RUN_HOST_REJECTED, + RUN_CLASSPATH_FAILED, ]; /// Category reported when registering the actions binding in a context failed. @@ -190,8 +197,18 @@ pub const RUN_CATEGORIES: &[&str] = &[ RUN_ACTIONS_REGISTRATION_FAILED, RUN_CONTEXTS_UNAVAILABLE, RUN_RESULT_SUMMARY_FAILED, + RUN_ACTIONS_QUERY_DEFERRED, + RUN_ACTIONS_QUERY_EXHAUSTED, + RUN_ACTIONS_PROGRESS_LIMIT, ]; +/// A cold JDT query is deferred until an upstream work-completion event. +pub const RUN_ACTIONS_QUERY_DEFERRED: &str = "run.actions_query_deferred"; +/// Automatic action refreshes reached their per-cycle budget. +pub const RUN_ACTIONS_QUERY_EXHAUSTED: &str = "run.actions_query_exhausted"; +/// Upstream progress exceeded the bounded per-workspace job set. +pub const RUN_ACTIONS_PROGRESS_LIMIT: &str = "run.actions_progress_limit"; + /// The run state of the backend: the actions binding per context and the /// result listener per running test launch. Shared between the functions, /// the notification handlers and the listener threads. @@ -200,6 +217,91 @@ pub struct RunSession { handle: BackendHandle, registrations: Mutex>, listeners: Mutex>>, + /// One bounded recovery record per active workspace, never per document. + action_queries: Mutex>, +} + +const MAX_ACTION_QUERY_REFRESHES: u8 = 3; +const MAX_ACTION_PROGRESS_JOBS: usize = 128; + +#[derive(Default)] +struct ActionQueryRetry { + completed_work: u64, + /// No more than three automatic refreshes in one deferred query cycle. + retries: u8, + pending: bool, + degraded: bool, + exhausted_reported: bool, + active_work: HashSet, + progress_limit: bool, +} + +impl ActionQueryRetry { + // A completed JDT job may race the timeout response. In that order the + // caller retries immediately; otherwise the next completion owns it. + fn defer(&mut self, started: u64) -> (bool, bool) { + let first = !self.degraded; + self.degraded = true; + let immediate = self.retries < MAX_ACTION_QUERY_REFRESHES + && !self.progress_limit + && self.active_work.is_empty() + && self.completed_work != started; + self.pending = self.retries < MAX_ACTION_QUERY_REFRESHES && !immediate; + if immediate { + self.retries += 1; + } + (first, immediate) + } + + fn progress(&mut self, id: &str, complete: bool) -> bool { + if complete { + self.active_work.remove(id); + if self.active_work.is_empty() && !self.progress_limit { + return self.work_completed(); + } + } else if self.active_work.contains(id) || self.active_work.len() < MAX_ACTION_PROGRESS_JOBS + { + self.active_work.insert(id.to_owned()); + } else { + // An untracked job must not make recovery run too early. + self.progress_limit = true; + } + false + } + + fn work_completed(&mut self) -> bool { + self.completed_work = self.completed_work.wrapping_add(1); + let retry = std::mem::take(&mut self.pending); + if retry { + self.retries += 1; + } + retry + } + + fn exhaustion(&mut self) -> bool { + self.retries == MAX_ACTION_QUERY_REFRESHES + && !std::mem::replace(&mut self.exhausted_reported, true) + } + + fn succeeded(&mut self) -> bool { + self.exhausted_reported = false; + self.progress_limit = false; + self.pending = false; + self.retries = 0; + std::mem::take(&mut self.degraded) + } +} + +struct ActionQueryState { + context_generation: u64, + server_generation: u64, + retry: ActionQueryRetry, +} + +#[derive(Clone, Copy)] +struct ActionQueryTicket { + server_generation: u64, + completed_work: u64, } /// The actions binding registered in one context generation. @@ -278,6 +380,7 @@ impl RunSession { settings, registrations: Mutex::new(HashMap::new()), listeners: Mutex::new(HashMap::new()), + action_queries: Mutex::new(HashMap::new()), } } @@ -343,9 +446,108 @@ impl RunSession { let _ = stale.subscription.dispose(); } } + lock(&self.action_queries).retain(|id, _| seen.contains(id)); Ok(failures) } + fn query_started(&self, reference: &WorkspaceContext) -> Option { + let server_generation = self.settings.running_server_generation(reference)?; + let mut states = lock(&self.action_queries); + let state = states + .entry(reference.context_id().to_owned()) + .or_insert_with(|| ActionQueryState { + context_generation: reference.generation(), + server_generation, + retry: ActionQueryRetry::default(), + }); + if state.context_generation != reference.generation() + || state.server_generation != server_generation + { + *state = ActionQueryState { + context_generation: reference.generation(), + server_generation, + retry: ActionQueryRetry::default(), + }; + } + Some(ActionQueryTicket { + server_generation, + completed_work: state.retry.completed_work, + }) + } + + fn query_finished( + &self, + reference: &WorkspaceContext, + ticket: Option, + deferred: bool, + ) { + let Some(ticket) = ticket else { + return; + }; + if !self + .settings + .accepts_server_notification(reference, ticket.server_generation) + { + return; + } + let (degraded, recovered, refresh, exhausted) = { + let mut states = lock(&self.action_queries); + let Some(state) = states.get_mut(reference.context_id()) else { + return; + }; + if state.context_generation != reference.generation() + || state.server_generation != ticket.server_generation + { + return; + } + if deferred { + let (first, refresh) = state.retry.defer(ticket.completed_work); + (first, false, refresh, !refresh && state.retry.exhaustion()) + } else { + (false, state.retry.succeeded(), false, false) + } + }; + // Categories and operation are compile-time constants accepted by the + // SDK. Recovery is reported once after a complete successful query. + if degraded { + let _ = self.handle.diagnostics().degraded( + COLLECT_FUNCTION_ID, + RUN_ACTIONS_QUERY_DEFERRED, + Some(1), + ); + } + if exhausted { + let _ = self.handle.diagnostics().degraded( + COLLECT_FUNCTION_ID, + RUN_ACTIONS_QUERY_EXHAUSTED, + Some(u64::from(MAX_ACTION_QUERY_REFRESHES)), + ); + } + if recovered { + let _ = self.handle.diagnostics().recovered( + COLLECT_FUNCTION_ID, + RUN_ACTIONS_QUERY_DEFERRED, + Some(1), + ); + } + if refresh { + self.refresh_actions(reference.context_id(), &|code, count| { + let _ = self + .handle + .diagnostics() + .degraded(COLLECT_FUNCTION_ID, code, Some(count)); + }); + } + } + + fn refresh_actions(&self, context_id: &str, report: &dyn Fn(&str, u64)) { + if let Some(stale) = lock(&self.registrations).remove(context_id) { + // log-exempt: expected absence: a removed context already disposed its binding. + let _ = stale.subscription.dispose(); + } + self.sync_contexts_reporting(report); + } + /// Runs [`Self::sync_contexts`] and reports its failures as `degraded` /// facts of the notification operation. pub fn sync_contexts_reporting(&self, report: &dyn Fn(&str, u64)) -> bool { @@ -395,9 +597,14 @@ impl RunSession { /// change. Progressive imports can finish additional modules after /// ServiceReady, so readiness alone is not the last collection boundary. pub fn did_receive_notification(&self, params: &Value, report: &dyn Fn(&str, u64)) { - let Some(context_id) = action_refresh_context(params) else { + let model_changed = action_refresh_context(params); + let progress = action_progress_notification(params); + if model_changed.is_none() && progress.is_none() { return; - }; + } + if params.get("serverID").and_then(Value::as_str) != Some(SERVER_ID) { + return; + } let Ok(reference) = WorkspaceContext::from_value(¶ms["context"]) else { return; }; @@ -410,10 +617,29 @@ impl RunSession { { return; } - if let Some(stale) = lock(&self.registrations).remove(&context_id) { - let _ = stale.subscription.dispose(); + let (retry, limited) = if let Some((id, complete)) = progress { + self.query_started(&reference); + let mut states = lock(&self.action_queries); + states + .get_mut(reference.context_id()) + .filter(|state| { + state.context_generation == reference.generation() + && state.server_generation == generation + }) + .map_or((false, false), |state| { + let was_limited = state.retry.progress_limit; + let retry = state.retry.progress(id, complete); + (retry, !was_limited && state.retry.progress_limit) + }) + } else { + (false, false) + }; + if limited { + report(RUN_ACTIONS_PROGRESS_LIMIT, 1); + } + if model_changed.is_some() || retry { + self.refresh_actions(reference.context_id(), report); } - self.sync_contexts_reporting(report); } /// Stops the result listener of a finished run. @@ -431,7 +657,11 @@ impl RunSession { /// /// Answers no actions when the language server is not running or busy: the /// gutter is empty until it is, and the host collects again on the next edit. -pub fn collect_actions_function(params: Value, context: &Context) -> Result { +pub fn collect_actions_function( + session: &RunSession, + params: Value, + context: &Context, +) -> Result { context.check_cancelled()?; let reference = workspace_context(context)?; let path = params @@ -440,8 +670,9 @@ pub fn collect_actions_function(params: Value, context: &Context) -> Result Result value, - Err(error) if is_transient(&error) => return Ok(json!({"actions": []})), + Err(error) if is_transient(&error) => { + session.query_finished(reference, ticket, true); + return Ok(json!({"actions": []})); + } Err(error) => return Err(language_server_failed(error)), }; context.check_cancelled()?; @@ -460,9 +694,13 @@ pub fn collect_actions_function(params: Value, context: &Context) -> Result value, - Err(error) if is_transient(&error) => return Ok(json!({"actions": []})), + Err(error) if is_transient(&error) => { + session.query_finished(reference, ticket, true); + return Ok(json!({"actions": []})); + } Err(error) => return Err(language_server_failed(error)), }; + session.query_finished(reference, ticket, false); let methods = normalize_java_main_methods(&main_methods) .map(|methods| methods.methods) .unwrap_or_default(); @@ -598,17 +836,29 @@ fn start(session: &RunSession, target: &Target, context: &Context) -> Result prepared, + Err(error) => { + if let Some(listener) = listener { + listener.stop(); + } + return Err(error); + } + }; let mut params = json!({ "context": reference.to_value(), "label": target.label, "process": { "executable": {"kind": "tool", "tool": RUN_TOOL}, - "arguments": process.arguments, + "arguments": arguments, "workingDirectory": process.working_directory, "environment": {}, }, "rerun": {"function": RERUN_FUNCTION_ID, "data": target.to_value()}, }); + if !temporary_files.is_empty() { + params["temporaryFiles"] = Value::Array(temporary_files); + } if let Some(port) = debug_port { params["debug"] = json!({ "configurationType": DEBUG_CONFIGURATION_TYPE, @@ -660,6 +910,55 @@ struct Process { working_directory: String, } +/// Adapts Java arguments to the host's temporary-file contract. +fn run_arguments( + process: &Process, + root: &Path, + windows: bool, +) -> Result<(Vec, Vec), FunctionError> { + if !windows { + return Ok((process.arguments.clone(), Vec::new())); + } + let working_directory = root.join(&process.working_directory); + let plan = plan_manifest_classpath_launch( + ClasspathJarRequest { + executable: "java.exe", + arguments: &process.arguments, + working_directory: &working_directory.to_string_lossy(), + classpath_jar_path: Path::new("${temporaryFile:classpath.jar}"), + limit: None, + java_feature_version: None, + style: ClasspathStyle::Windows, + }, + |path| Path::new(path).is_dir(), + ); + let ClasspathJarPlan::ClasspathJar { + arguments, + manifest, + } = plan + else { + return Ok((process.arguments.clone(), Vec::new())); + }; + let bytes = manifest_jar(&manifest) + .map_err(|error| failure(RUN_CLASSPATH_FAILED, error.to_string()))?; + Ok(( + arguments, + vec![json!({"name": "classpath.jar", "base64": BASE64.encode(bytes)})], + )) +} + +/// The host writes these bytes under its scratch directory and removes them +/// with the run, including failure and cancellation. No plugin file is changed. +fn manifest_jar(manifest: &str) -> Result, zip::result::ZipError> { + let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new())); + archive.start_file( + "META-INF/MANIFEST.MF", + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored), + )?; + archive.write_all(manifest.as_bytes())?; + Ok(archive.finish()?.into_inner()) +} + /// Builds the workspace and resolves the classpath of a main class. fn main_process( host: &Host, @@ -753,7 +1052,7 @@ fn test_process( let found = execute_command( host, reference, - java_test_items_command(&file_uri(&file_path)), + java_test_items_command(&file_uri(&file_path)?), LAUNCH_REQUEST_TIMEOUT_MILLISECONDS, ) .map_err(language_server_failed)?; @@ -1097,18 +1396,17 @@ fn free_port() -> Result { } /// `file:` URI of an absolute path, percent-encoding what RFC 3986 reserves. -pub(crate) fn file_uri(path: &Path) -> String { - let text = path.to_string_lossy(); - let mut uri = String::from("file://"); - for byte in text.bytes() { - let keep = byte.is_ascii_alphanumeric() || b"-._~/".contains(&byte); - if keep { - uri.push(byte as char); - } else { - uri.push_str(&format!("%{byte:02X}")); - } - } - uri +pub(crate) fn file_uri(path: &Path) -> Result { + // The URL crate owns drive letters, UNC, verbatim prefixes and escaping. + // Reject paths it cannot represent instead of silently changing characters. + url::Url::from_file_path(path) + .map(String::from) + .map_err(|()| { + PluginError::new( + RUN_INVALID_PARAMS, + "The file path must be an absolute native path.", + ) + }) } /// Path separator of the `-cp` and `--module-path` values. @@ -1121,6 +1419,17 @@ fn failure(code: &'static str, message: impl Into) -> FunctionError { /// The context of a JDT readiness or project/classpath change. Other semantic /// notifications do not invalidate run markers. Accept both upstream enum /// names and their published numeric values without inspecting project data. +fn action_progress_notification(params: &Value) -> Option<(&str, bool)> { + if params.get("method").and_then(Value::as_str) != Some("language/progressReport") { + return None; + } + let id = params + .pointer("/params/id")? + .as_str() + .filter(|id| !id.is_empty() && id.len() <= 128)?; + Some((id, params.pointer("/params/complete")?.as_bool()?)) +} + fn action_refresh_context(params: &Value) -> Option { if params.get("serverID").and_then(Value::as_str) != Some(SERVER_ID) { return None; @@ -1155,6 +1464,84 @@ mod tests { use super::*; use lithe_java_domain::lsp::languages::JavaRunMarkerStatus; + #[test] + fn a_deferred_query_retries_at_a_work_boundary_in_either_order() { + let mut retry = ActionQueryRetry::default(); + assert_eq!(retry.defer(0), (true, false)); + assert!(retry.work_completed()); + assert!( + !retry.work_completed(), + "one boundary consumes the pending retry" + ); + assert!(retry.succeeded(), "report recovery once"); + assert!(!retry.succeeded()); + + let start = retry.completed_work; + assert!(!retry.work_completed()); + assert_eq!( + retry.defer(start), + (true, true), + "completion raced the timeout" + ); + assert!(!retry.work_completed()); + assert_eq!(retry.defer(retry.completed_work), (false, false)); + assert!(retry.work_completed()); + } + + #[test] + fn automatic_query_recovery_has_a_budget_and_success_rearms_it() { + let mut retry = ActionQueryRetry::default(); + for _ in 0..MAX_ACTION_QUERY_REFRESHES { + assert!(!retry.defer(retry.completed_work).1); + assert!(retry.work_completed()); + } + assert!(!retry.defer(retry.completed_work).1); + assert!( + !retry.work_completed(), + "no retry loop if JDT keeps timing out" + ); + assert!(retry.succeeded()); + assert!(!retry.defer(retry.completed_work).1); + assert!(retry.work_completed()); + } + + #[test] + fn indexing_is_not_retried_when_an_unrelated_job_ends() { + let mut retry = ActionQueryRetry::default(); + assert!(!retry.progress("index", false)); + assert_eq!(retry.defer(0), (true, false)); + assert!(!retry.progress("build", false)); + assert!(!retry.progress("build", true)); + assert!(!retry.defer(0).1, "still busy indexing"); + assert!(retry.progress("index", true)); + assert!( + !retry.progress("index", true), + "duplicate completion does not refresh" + ); + } + + #[test] + fn progress_bookkeeping_is_bounded_and_malformed_events_do_not_recover() { + let mut retry = ActionQueryRetry::default(); + for id in 0..MAX_ACTION_PROGRESS_JOBS + 1 { + retry.progress(&id.to_string(), false); + } + assert_eq!(retry.active_work.len(), MAX_ACTION_PROGRESS_JOBS); + assert!(retry.progress_limit); + for value in [ + json!({"method":"$/progress","params":{"value":{"kind":"end"}}}), + json!({"method":"language/progressReport","params":{"complete":true}}), + ] { + assert!(action_progress_notification(&value).is_none()); + } + assert_eq!( + action_progress_notification( + &json!({"method":"language/progressReport","params":{"id":"index","complete":true}}) + ), + Some(("index", true)) + ); + } + #[test] fn compiler_errors_do_not_launch_previous_bytecode() { assert!(require_successful_build(&json!(BUILD_SUCCEEDED)).is_ok()); @@ -1332,13 +1719,37 @@ mod tests { } #[test] + #[cfg(not(windows))] fn file_uris_percent_encode_reserved_characters() { assert_eq!( - file_uri(Path::new("/Users/me/My Project/src/Main.java")), + file_uri(Path::new("/Users/me/My Project/src/Main.java")).unwrap(), "file:///Users/me/My%20Project/src/Main.java" ); } + #[test] + #[cfg(windows)] + fn windows_file_uris_preserve_drives_unc_and_unicode() { + for (path, expected) in [ + ( + r"C:\old project\中文\Main.java", + "file:///C:/old%20project/%E4%B8%AD%E6%96%87/Main.java", + ), + (r"\\server\share\Main.java", "file://server/share/Main.java"), + ( + r"\\?\C:\old project\Main.java", + "file:///C:/old%20project/Main.java", + ), + ] { + assert_eq!(file_uri(Path::new(path)).unwrap(), expected); + } + } + + #[test] + fn file_uris_reject_relative_paths() { + assert!(file_uri(Path::new("relative/Main.java")).is_err()); + } + #[test] fn run_codes_are_valid_and_distinct() { let mut seen = std::collections::BTreeSet::new(); @@ -1347,4 +1758,54 @@ mod tests { assert!(seen.insert(*code), "{code} is listed twice"); } } + + #[test] + fn oversized_windows_classpath_uses_a_host_owned_binary_file() { + let classpath = (0..500) + .map(|i| { + format!( + "C:\\old project\\中文 modules\\dependency-{i:04}\\a-library-version-1.0.0.jar" + ) + }) + .collect::>() + .join(";"); + let process = Process { + arguments: vec![ + "-Dfixture=true".into(), + "-cp".into(), + classpath, + "demo.Main".into(), + "hello world".into(), + ], + working_directory: ".".into(), + }; + let (arguments, files) = + run_arguments(&process, Path::new("C:\\old project"), true).unwrap(); + assert_eq!(arguments[2], "${temporaryFile:classpath.jar}"); + assert_eq!(arguments[3..], ["demo.Main", "hello world"]); + assert_eq!(files.len(), 1); + assert_eq!(files[0]["name"], "classpath.jar"); + assert!(files[0]["base64"] + .as_str() + .is_some_and(|data| !data.is_empty())); + let bytes = BASE64.decode(files[0]["base64"].as_str().unwrap()).unwrap(); + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + assert_eq!(archive.len(), 1); + let mut manifest = String::new(); + std::io::Read::read_to_string( + &mut archive.by_name("META-INF/MANIFEST.MF").unwrap(), + &mut manifest, + ) + .unwrap(); + assert!(manifest.is_ascii()); + assert!( + manifest.starts_with("Manifest-Version: 1.0\r\nClass-Path: file:/C:/old%20project/") + ); + assert!(manifest.contains("%E4%B8%AD%E6%96%87")); + assert!(manifest.split("\r\n").all(|line| line.len() <= 72)); + let (posix_arguments, posix_files) = + run_arguments(&process, Path::new("/work"), false).unwrap(); + assert_eq!(posix_arguments, process.arguments); + assert!(posix_files.is_empty()); + } } diff --git a/backend/src/settings_session.rs b/backend/src/settings_session.rs index 6f394ac..fef8dd0 100644 --- a/backend/src/settings_session.rs +++ b/backend/src/settings_session.rs @@ -548,7 +548,7 @@ impl SettingsSession { self.running_server_generation(context) == Some(generation) } - fn running_server_generation(&self, context: &WorkspaceContext) -> Option { + pub(crate) fn running_server_generation(&self, context: &WorkspaceContext) -> Option { self.lock_states() .get(context.context_id()) .filter(|state| state.context.generation() == context.generation()) diff --git a/crates/java-domain/Cargo.toml b/crates/java-domain/Cargo.toml index d3f4c55..e0bab94 100644 --- a/crates/java-domain/Cargo.toml +++ b/crates/java-domain/Cargo.toml @@ -15,3 +15,9 @@ tree-sitter.workspace = true tree-sitter-java.workspace = true url.workspace = true zip.workspace = true + +[dev-dependencies] +filetime = "0.2.26" + +[target.'cfg(windows)'.dev-dependencies] +junction = { version = "2.1.0", default-features = false } diff --git a/crates/java-domain/src/execution/classpath_jar.rs b/crates/java-domain/src/execution/classpath_jar.rs index 85821fa..dc07189 100644 --- a/crates/java-domain/src/execution/classpath_jar.rs +++ b/crates/java-domain/src/execution/classpath_jar.rs @@ -85,6 +85,23 @@ pub enum ClasspathJarPlan { pub fn plan_classpath_jar_launch( request: ClasspathJarRequest<'_>, is_directory: impl Fn(&str) -> bool, +) -> ClasspathJarPlan { + if request + .java_feature_version + .is_none_or(|version| version >= FIRST_ARGFILE_JAVA_VERSION) + { + return ClasspathJarPlan::Direct; + } + plan_manifest_classpath_launch(request, is_directory) +} + +/// Shortens a class path using a manifest JAR on every supported Java release. +/// Unlike native-encoded argument files, its ASCII file URLs preserve Unicode +/// independently of the Windows code page. Module-path arguments stay unchanged. +/// This entry point deliberately does not inspect `java_feature_version`. +pub fn plan_manifest_classpath_launch( + request: ClasspathJarRequest<'_>, + is_directory: impl Fn(&str) -> bool, ) -> ClasspathJarPlan { let executable_name = request .executable @@ -94,9 +111,6 @@ pub fn plan_classpath_jar_launch( if !["java", "java.exe", "javaw", "javaw.exe"] .iter() .any(|name| executable_name.eq_ignore_ascii_case(name)) - || request - .java_feature_version - .is_none_or(|version| version >= FIRST_ARGFILE_JAVA_VERSION) { return ClasspathJarPlan::Direct; } diff --git a/crates/java-domain/src/jdt_integration/workspace_state.rs b/crates/java-domain/src/jdt_integration/workspace_state.rs index b435d0e..1520320 100644 --- a/crates/java-domain/src/jdt_integration/workspace_state.rs +++ b/crates/java-domain/src/jdt_integration/workspace_state.rs @@ -358,7 +358,7 @@ fn io_error(message: &str, error: &io::Error) -> CoreError { #[cfg(test)] mod tests { use super::*; - use std::fs::File; + use std::time::Duration; struct Fixture { @@ -377,7 +377,7 @@ mod tests { let _ = fs::remove_dir_all(&root); let workspace = root.join("workspace"); let cache = root.join("cache"); - let bin = root.join("jdtls/bin"); + let bin = root.join("jdtls").join("bin"); for directory in [&workspace, &cache, &bin] { fs::create_dir_all(directory).unwrap(); } @@ -400,10 +400,11 @@ mod tests { } fn set_modified(path: &Path, seconds: u64) { - File::open(path) - .unwrap() - .set_modified(UNIX_EPOCH + Duration::from_secs(seconds)) - .unwrap(); + filetime::set_file_mtime( + path, + filetime::FileTime::from_unix_time(seconds.try_into().unwrap(), 0), + ) + .unwrap(); } #[test] @@ -519,7 +520,12 @@ mod tests { for key in [&expired, &recent, &active] { fs::create_dir_all(jdtls.join(key)).unwrap(); } + #[cfg(unix)] std::os::unix::fs::symlink(jdtls.join(&recent), jdtls.join(&linked)).unwrap(); + // A local NTFS junction exercises the alias boundary without requiring + // Administrator or Developer Mode merely to run this test. + #[cfg(windows)] + junction::create(jdtls.join(&recent), jdtls.join(&linked)).unwrap(); fs::create_dir_all(jdtls.join("not-a-cache-key")).unwrap(); set_modified(&jdtls.join(&expired), 1_000_000); set_modified(&jdtls.join(&recent), 3_999_999); diff --git a/crates/java-domain/src/languages/code_vision_hints.rs b/crates/java-domain/src/languages/code_vision_hints.rs index 890fc5b..5c15d85 100644 --- a/crates/java-domain/src/languages/code_vision_hints.rs +++ b/crates/java-domain/src/languages/code_vision_hints.rs @@ -51,7 +51,7 @@ pub fn workspace_relative_path(file: &Path, root: &Path) -> Option { let root = normalize(root); let relative = file.strip_prefix(&root).ok()?; let relative = relative.to_str()?; - (!relative.is_empty()).then(|| relative.to_string()) + (!relative.is_empty()).then(|| relative.replace(std::path::MAIN_SEPARATOR, "/")) } /// Hints for `file`; a file outside the workspace or a failed scan has none. diff --git a/crates/java-domain/src/lsp/languages/jdt.rs b/crates/java-domain/src/lsp/languages/jdt.rs index 1b50ec2..a8c4fb5 100644 --- a/crates/java-domain/src/lsp/languages/jdt.rs +++ b/crates/java-domain/src/lsp/languages/jdt.rs @@ -471,6 +471,13 @@ pub fn adapt_initialization_options( .as_object_mut() .expect("the extended capabilities were normalized to an object") .insert("classFileContentsSupport".to_string(), Value::Bool(true)); + // JDT LS 1.61's ProgressReporterManager prefers this public extension + // over LSP work-done progress. Its completed jobs reach the plugin through + // the already declared language/progressReport notification. + extended + .as_object_mut() + .unwrap() + .insert("progressReportProvider".to_string(), Value::Bool(true)); if !java_extension_bundle_paths.is_empty() { let bundles = options @@ -2370,6 +2377,10 @@ mod tests { options["extendedClientCapabilities"]["classFileContentsSupport"], true ); + assert_eq!( + options["extendedClientCapabilities"]["progressReportProvider"], + true + ); assert_eq!( options["bundles"], json!([ diff --git a/io.github.lithe-idea.java-support/plugin.json b/io.github.lithe-idea.java-support/plugin.json index b40cf79..6aed604 100644 --- a/io.github.lithe-idea.java-support/plugin.json +++ b/io.github.lithe-idea.java-support/plugin.json @@ -149,6 +149,8 @@ "launch": [ "${executable}", "${runtimeArguments}", + "-jar", + "${entry}", "${arguments}" ] } diff --git a/scripts/package/build-windows.mjs b/scripts/package/build-windows.mjs new file mode 100644 index 0000000..e9e2f74 --- /dev/null +++ b/scripts/package/build-windows.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Assemble on macOS, using the backend built from this checkout on Windows. +// All runtime inputs come from the committed manifests; installed packages are +// never written. This is a development package, not a release/publishing entry. +import { createHash } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +if (process.platform !== 'darwin') throw new Error('Assemble this development package on macOS.'); +const backend = process.argv[2]; +if (!backend) throw new Error('Usage: node scripts/package/build-windows.mjs |--prepare-runtime'); +const artifacts = join(root, '.artifacts'); +const jdk = JSON.parse(await readFile(join(root, 'third_party/jdk/manifest.json'), 'utf8')); +const input = jdk.platforms['windows-x86_64']; +const cache = join(artifacts, 'jdk-downloads'); +await mkdir(cache, { recursive: true }); +const archive = join(cache, `jdk-windows-x86_64-${input.sha256}.zip`); +async function digest(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} +async function isVerified(path) { + try { return await digest(path) === input.sha256; } + catch (error) { if (error.code === 'ENOENT') return false; throw error; } +} +if (!await isVerified(archive)) { + const temporary = `${archive}.${process.pid}.download`; + try { + const response = await fetch(input.url, { signal: AbortSignal.timeout(180_000) }); + if (!response.ok) throw new Error(`JDK download failed: ${response.status}`); + await pipeline(Readable.fromWeb(response.body), createWriteStream(temporary, { flags: 'wx' })); + if (!await isVerified(temporary)) throw new Error('Windows JDK SHA-256 mismatch.'); + await rename(temporary, archive); + } finally { await rm(temporary, { force: true }); } +} +if (backend === '--prepare-runtime') { + process.stdout.write(`${archive}\n`); + process.exit(0); +} +async function verifyPE(path) { + const bytes = await readFile(path); + const header = bytes.length >= 64 ? bytes.readUInt32LE(60) : bytes.length; + if (bytes.toString('ascii', 0, 2) !== 'MZ' || header + 6 > bytes.length || bytes.readUInt32LE(header) !== 0x4550 || bytes.readUInt16LE(header + 4) !== 0x8664) { + throw new Error('A Windows x64 PE executable is required.'); + } +} +await verifyPE(resolve(backend)); +// This existing entry verifies JDT LS, Lombok, java-debug and java-test from +// their locked archives and licenses, including config_win. +const jdt = execFileSync(join(root, 'scripts/prepare-jdtls.sh'), { + encoding: 'utf8', env: { ...process.env, LITHE_ARCH: 'x86_64' }, + stdio: ['ignore', 'pipe', 'inherit'], timeout: 600_000, +}).trim(); +const staging = join(artifacts, `windows-package-${process.pid}`); +const id = 'io.github.lithe-idea.java-support'; +const packageRoot = join(staging, id); +try { + await mkdir(packageRoot, { recursive: true }); + const unpack = join(staging, 'jdk'); + await mkdir(unpack); + execFileSync('/usr/bin/unzip', ['-q', archive, '-d', unpack], { timeout: 120_000 }); + const runtime = join(unpack, input.jdkRoot); + await verifyPE(join(runtime, 'bin/java.exe')); + const release = await readFile(join(runtime, 'release'), 'utf8'); + if (!/^JAVA_VERSION="21\./m.test(release)) throw new Error('The pinned runtime must be Java 21.'); + await mkdir(join(packageRoot, 'runtime'), { recursive: true }); + await cp(runtime, join(packageRoot, 'runtime/x86_64'), { recursive: true }); + for (const name of ['icons', 'templates']) await cp(join(root, id, name), join(packageRoot, name), { recursive: true }); + const languageServer = join(packageRoot, 'language-server'); + await mkdir(languageServer); + for (const name of ['plugins', 'features', 'lombok', 'java-debug', 'java-test', 'manifest.json', 'LICENSE-EPL-2.0.txt']) { + await cp(join(jdt, name), join(languageServer, name), { recursive: true }); + } + await mkdir(join(languageServer, 'configuration/x86_64'), { recursive: true }); + await cp(join(jdt, 'config_win/config.ini'), join(languageServer, 'configuration/x86_64/config.ini')); + const manifest = JSON.parse(await readFile(join(root, id, 'plugin.json'), 'utf8')); + manifest.backend.launch.path = 'bin/lithe-java-backend.exe'; + await writeFile(join(packageRoot, 'plugin.json'), `${JSON.stringify(manifest, null, 2)}\n`); + await mkdir(join(packageRoot, 'bin')); + await cp(resolve(backend), join(packageRoot, manifest.backend.launch.path)); + const target = join(artifacts, `Lithe-Java-Support-${manifest.version}-windows-x64.zip`); + execFileSync('/usr/bin/ditto', ['-c', '-k', '--keepParent', packageRoot, target], { timeout: 120_000 }); + await writeFile(`${target}.sha256`, `${await digest(target)} ${target.split('/').at(-1)}\n`); + process.stdout.write(`${target}\n`); +} finally { await rm(staging, { recursive: true, force: true }); }