From f0997a0e7bad7972ed1f6b241f7dbe6d491f44dc Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 1 Oct 2026 15:45:25 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(windows):=20JDK=208=20=E8=B6=85?= =?UTF-8?q?=E9=95=BF=20classpath=20=E6=94=B9=E7=94=A8=E4=B8=B4=E6=97=B6=20?= =?UTF-8?q?manifest=20JAR=20=E5=90=AF=E5=8A=A8=20(#955)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JDK 8 不支持 @argfile,Windows 普通 Run 遇到超长 classpath 时 CreateProcessW 直接失败。现在已确认 JDK < 9 时由 Core 规划一个只含 META-INF/MANIFEST.MF 的临时 classpath JAR(Class-Path 写绝对、 百分号编码的 file: URL,目录以 / 结尾,72 字节折行),宿主写到 temp_dir()/lithe-run 并沿用 argfile 的独占创建与 RAII 清理。 JDK 9+ 仍走 argfile,版本未知保持原命令。 Co-Authored-By: Claude --- ...026-09-20-oversized-java-launch-command.md | 56 +- docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- .../lithe-core/src/execution/classpath_jar.rs | 577 ++++++++++++++++++ .../src/execution/launch_command.rs | 7 +- rust/lithe-core/src/execution/mod.rs | 4 + shared/contracts/rust-core-api.md | 12 + shared/platform-feature-matrix.json | 5 +- windows/tauri/src-tauri/src/run.rs | 2 +- .../src-tauri/src/run/launch_arguments.rs | 206 ++++++- 10 files changed, 843 insertions(+), 30 deletions(-) create mode 100644 rust/lithe-core/src/execution/classpath_jar.rs diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md b/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md index a51982121..a7dfb9c7c 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md +++ b/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md @@ -11,6 +11,8 @@ Java 项目现在由项目 JDK 直接启动,JDT 解析出的运行时 classpat `execution.planLaunchCommand` JSON 命令自动采用该策略,不需要 Windows 专属配置。Windows 集成终端对 `cmd.exe` 和 PowerShell 的超长多行输入改用临时脚本调用,脚本执行后自删除,终端关闭时也会清理未执行文件;这不修改系统 Shell 配置。同时启动失败的真实原因必须能显示出来,不能再被兜底文案吞掉。 +JDK 8 不认识 argfile,Windows 普通 Run 改用 classpath JAR(只含 `META-INF/MANIFEST.MF` +的临时 JAR,靠 manifest 的 `Class-Path` 列出全部条目);Core 生成 manifest,宿主写入并沿用同一套临时文件生命周期(#955)。 ## 问题 @@ -49,13 +51,29 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 不能因为 JDK 18+ 就写 UTF-8。Windows 宿主按实际 ANSI 代码页转换 Core 返回的 Unicode 文本,并回转校验,不能表示的字符报错,不允许替换成问号。 UTF-8 系统代码页才使用 UTF-8 字节;普通中文系统代码页使用对应的中文编码。 -5. **失败必须可见。** 宿主的失败消息带上系统错误、可执行文件和命令长度; +5. **JDK 8 用 classpath JAR,规则同样在 Core。** 已确认 JDK 版本低于 9 时,Windows 宿主调用 + `lithe_core::execution::plan_classpath_jar_launch`。它使用同一长度预算,只替换启动器 + 最终生效的那个 `-cp`/`-classpath` 值(多次出现时最后一个生效),值换成临时 JAR 路径; + JVM 选项、主类和程序参数不动。manifest 规则: + - 每个条目先按工作目录转成绝对路径,因为 manifest 里的相对 URL 以 JAR 所在目录为基准, + 而命令行里的相对路径以工作目录为基准;空条目按启动器语义视为工作目录。 + - 绝对路径转成 `file:` URL:盘符写成 `file:/C:/...`,UNC 写成 `file:////host/share/...`; + 非 `[A-Za-z0-9-._~/:]` 的字节一律按 UTF-8 百分号编码(空格、中文、`#`、`%`、`+` 都包括在内)。 + 所以 manifest 是纯 ASCII,和 Windows 系统代码页无关,比 argfile 更不容易因中文路径失败。 + - 目录条目必须以 `/` 结尾,否则类加载器把它当成 JAR 打开。目录判断是文件系统事实, + 由宿主通过回调回答,Core 不读磁盘。 + - 每行最多 72 字节,续行以一个空格开头,换行用 CRLF,最后用空行结束主段。 + - 不能等价表达的情况保持原命令:`lib/*` 通配符(只在命令行展开)、`C:lib` 这类依赖 + 每盘当前目录的路径、`-jar` 启动(会忽略 `-cp`)、版本未知。 + JAR 只含一个不压缩的 manifest 条目,写在 `temp_dir()/lithe-run/launch--.classpath.jar`, + 和 argfile 共用 `create_new` 独占创建与 RAII 清理,绝不写进 JDK 或安装目录。 +6. **失败必须可见。** 宿主的失败消息带上系统错误、可执行文件和命令长度; 前端把非 `Error` 的拒绝值也转成可读文本,并通过 `frontendTrace` 写日志。 -6. **macOS 复用同一份规划。** macOS 组合根把 `RustCoreBridge` 注入 +7. **macOS 复用同一份规划。** macOS 组合根把 `RustCoreBridge` 注入 `LitheExecutionModule.RunService` 的 `JavaLaunchArgumentPreparing` 端口。适配器读取 JDK `release` 版本、调用 `execution.planLaunchCommand`、以 UTF-8 写入自己的临时文件,并把一个 lease 保留到 Java 进程退出;RunService 不直接操作文件系统。 -7. **终端只在 IDE 输入边界做短调用转换。** Windows `cmd.exe` 和 PowerShell 的单行输入有独立长度上限。 +8. **终端只在 IDE 输入边界做短调用转换。** Windows `cmd.exe` 和 PowerShell 的单行输入有独立长度上限。 终端连接收到超过 7000 个 UTF-16 单元且包含换行的文本时,在系统临时目录使用 `create_new` 创建 `.cmd` 或 `.ps1`, 把原文本写入脚本,再向 PTY 写入短的 `call "路径"` 或 `& '路径'`。短文本、仍处于编辑状态的超长单行文本、二进制输入、 WSL 和 Git Bash 不做转换,避免改变交互式 Shell 的解析语义。脚本自身负责正常执行后的删除,连接关闭或写入失败时由连接对象尽力删除剩余文件。 @@ -75,9 +93,13 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 - **改用 `CLASSPATH` 环境变量**:被否。Windows 对单个环境变量和整个环境块同样有 32767 的限制,只是把同一个上限换了个地方。 -- **生成 pathing jar**(用 manifest 的 `Class-Path` 间接引用):暂不采用。它能兼容 - JDK 8,但要处理相对 URL 编码和路径空格,复杂度明显高于 argfile。等真的出现 - JDK 8 大工程需求再做。 +- **所有 JDK 都用 pathing jar**(classpath JAR):被否。#955 出现 JDK 8 需求后,classpath JAR + 只用于 JDK 8。JDK 9+ 继续用 argfile,因为 argfile 还能搬 `--module-path`, + 也不会改变 `java.class.path` 系统属性(classpath JAR 下该属性只剩 JAR 本身, + 依赖它扫描类路径的少数框架行为会变)。 +- **JDK 8 用 `CLASSPATH` 环境变量**:被否,理由同上,环境块同样受 32767 限制。 +- **manifest 写相对 URL**:被否。临时 JAR 和项目通常不在同一盘符,相对 URL 无法表达; + 绝对 `file:` URL 对所有盘符和 UNC 路径都成立。 - **退回 Maven 启动**:被否。那会把 `.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md` 里已经解决的 `ClassNotFoundException` 重新带回来。 @@ -95,11 +117,16 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 RAII 所有者管理(离开作用域时自动清理):写入或启动失败时删除,成功后交给 该进程的退出线程删除;旧执行的清理不能影响替代它的新执行。 JVM 已读取参数后,外部清理文件不影响该进程;后续执行会创建新文件。 -- `@argfile` 需要 JDK 9 以上。JDK 8 上的超长 classpath 仍然无解,但那种情况 - 今天本来就会失败,所以不构成回退。 +- `@argfile` 需要 JDK 9 以上。JDK 8 的普通 Run 现在由 classpath JAR 覆盖。代价: + JDK 8 缩短后 `System.getProperty("java.class.path")` 只返回临时 JAR 路径; + 只依赖类加载器的代码不受影响。JDK 8 的 `--module-path` 不存在,所以不需要处理。 + macOS 的 `ARG_MAX` 远大于 Windows 上限,JDK 8 直接启动不会超限,因此 macOS 不调用 + 这条规划,也没有 JSON 命令。 +- JDK 8 缩短不覆盖:Java 测试调试(Java Debug Server 自己拉起 JVM)、Maven 目标、 + 集成终端里手动执行的命令。它们都不经过 `run_start_process`。 - 参数文件使用 Windows 实际系统代码页,JDK 9-17 的中文路径也可在能够无损表示 - 它们的系统代码页下使用。不支持该字符的系统代码页仍会明确失败;pathing jar - 可以作为后续兼容方案。不能把更改 `file.encoding` 当作启动器编码的修复。 + 它们的系统代码页下使用。不支持该字符的系统代码页仍会明确失败;JDK 9+ 是否也改用 + classpath JAR 规避代码页问题,需要先权衡 `java.class.path` 语义变化,本次未做。不能把更改 `file.encoding` 当作启动器编码的修复。 - Windows 集成终端只为 `cmd.exe` 和 PowerShell 的超长多行文本创建一次性脚本;WSL、Git Bash 和普通交互输入保留原始写入。 这解决 IDE 生成命令的 Shell 输入上限,不改变系统 Shell 的全局限制。 - Java **测试**的调试仍由 Java Debug Server 自己拉起 JVM,它的 @@ -113,8 +140,12 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 以及可配置上限;`execution.planLaunchCommand` 复用同一规划器。 - macOS Swift:`swift build --target Lithe` 覆盖模块边界和组合根注入;临时文件 lease 在启动失败、停止和正常退出路径释放。 +- Rust Core:`cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib classpath_jar` + 覆盖 JDK 8 触发、JDK 9+/未知版本/短命令保持直接启动、空格中文和保留字符的百分号编码、 + 目录结尾 `/`、相对/UNC/空条目、72 字节折行、只替换生效的 `-cp`,以及通配符和 `-jar` 不改写。 - Windows 宿主:`cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml run::` - 覆盖写入并删除 argfile、普通启动不写文件、失败消息包含系统原因。该 crate 需要 + 覆盖写入并删除 argfile、普通启动不写文件、失败消息包含系统原因;JDK 8 生成只含 manifest 的 + 临时 JAR、目录条目带 `/`、所有者释放后删除,JDK 9+ 仍走 argfile,版本未知不写文件。该 crate 需要 Windows 或具备 GTK 依赖的环境才能编译。 - Windows 终端 crate:`cargo test --manifest-path windows/tauri/crates/terminal/Cargo.toml` 覆盖短输入和单行输入不转换、 `cmd.exe`/PowerShell 脚本内容与自删除命令;`cargo check --target x86_64-pc-windows-msvc` 检查 Windows 条件编译。 @@ -123,7 +154,8 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 ## 适用范围 -- Rust Core:`rust/lithe-core/src/execution/launch_command.rs` +- Rust Core:`rust/lithe-core/src/execution/launch_command.rs`、 + `rust/lithe-core/src/execution/classpath_jar.rs` - Windows:`windows/tauri/src-tauri/src/run.rs`、 `windows/tauri/src-tauri/src/run/launch_arguments.rs`、 `windows/tauri/src/features/run/stores/run.store.ts`、 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 767f5eb16..af6f05a25 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -61,7 +61,7 @@ lsp-navigation-edits,Java,语言服务,跳转、引用、语义标记与编辑, run-discovery,运行与调试,运行配置,入口点与运行配置发现,已实现,待验证,已实现,待验证,Run,验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。,,macos/Sources/Lithe/Views/Run; shared/fixtures/run-configuration,windows/tauri/src/features/run; shared/fixtures/run-configuration run-save-toolchain,运行与调试,运行配置,保存前同步与工具链解析,已实现,待验证,已实现,待验证,Run,修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java,windows/tauri/src/features/run; windows/tauri/src/features/maven run-java-test,运行与调试,运行配置,Java main 与测试运行,已实现,待验证,已实现,待验证,Run,运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。,,macos/Sources/Lithe/Views/Run; shared/fixtures/debug,windows/tauri/src/features/run; shared/fixtures/debug; windows/tauri/src/features/run/components/run-pane.tsx; windows/tauri/src/features/run/components/run-output-text.tsx -run-java-long-classpath,运行与调试,运行配置,超长 Java 类路径自动缩短,已实现,待验证,已实现,待验证,Run,使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。,,macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift; macos/Sources/LitheExecutionModule/Services/RunService.swift; rust/lithe-core/src/execution/launch_command.rs,windows/tauri/src-tauri/src/run/launch_arguments.rs; rust/lithe-core/src/execution/launch_command.rs +run-java-long-classpath,运行与调试,运行配置,超长 Java 类路径自动缩短,已实现,待验证,已实现,待验证,Run,使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。,,macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift; macos/Sources/LitheExecutionModule/Services/RunService.swift; rust/lithe-core/src/execution/launch_command.rs,windows/tauri/src-tauri/src/run/launch_arguments.rs; rust/lithe-core/src/execution/launch_command.rs; rust/lithe-core/src/execution/classpath_jar.rs debug-breakpoints,运行与调试,调试器,启动调试与断点,已实现,待验证,部分实现,待验证,Debug,设置、命中、禁用和重新定位断点,确认调试会话生命周期。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug debug-state,运行与调试,调试器,变量、异常与断开策略,已实现,待验证,部分实现,待验证,Debug,验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug terminal-shell,工作台,终端,Shell 发现与配置,已实现,待验证,已实现,待验证,Terminal,验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效,以及环境变量、工作目录和不可用 Shell 的提示。,,macos/Sources/Lithe/Views/Terminal; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Services,windows/tauri/src/features/terminal; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/lib/default-shell-options.ts; windows/tauri/src-tauri diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 37a0ad01b..cac928c42 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -167,7 +167,7 @@ | 运行配置 | **入口点与运行配置发现**
run-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/run-configuration` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/run-configuration` | Run | 验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。 | | | 运行配置 | **保存前同步与工具链解析**
run-save-toolchain | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`windows/tauri/src/features/maven` | Run | 修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。 | | | 运行配置 | **Java main 与测试运行**
run-java-test | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/debug` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/debug`、`windows/tauri/src/features/run/components/run-pane.tsx`、`windows/tauri/src/features/run/components/run-output-text.tsx` | Run | 运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 | | -| 运行配置 | **超长 Java 类路径自动缩短**
run-java-long-classpath | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift`、`macos/Sources/LitheExecutionModule/Services/RunService.swift`、`rust/lithe-core/src/execution/launch_command.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src-tauri/src/run/launch_arguments.rs`、`rust/lithe-core/src/execution/launch_command.rs` | Run | 使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 | | +| 运行配置 | **超长 Java 类路径自动缩短**
run-java-long-classpath | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift`、`macos/Sources/LitheExecutionModule/Services/RunService.swift`、`rust/lithe-core/src/execution/launch_command.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src-tauri/src/run/launch_arguments.rs`、`rust/lithe-core/src/execution/launch_command.rs`、`rust/lithe-core/src/execution/classpath_jar.rs` | Run | 使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。 | | | 调试器 | **启动调试与断点**
debug-breakpoints | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/debugger`、`shared/fixtures/debug` | Debug | 设置、命中、禁用和重新定位断点,确认调试会话生命周期。 | Windows 真实调试产品链路仍在 #466 跟进。 | | 调试器 | **变量、异常与断开策略**
debug-state | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/debugger`、`shared/fixtures/debug` | Debug | 验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。 | Windows 真实调试产品链路仍在 #466 跟进。 | | 项目运行 | **Docker / Compose 项目识别与运行**
docker-compose | 🟡 部分实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/RunConfigurationIcon.swift`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/docker`、`windows/tauri/src/features/run` | Run / Docker | 使用 Dockerfile 和 Compose fixture 验证识别、命令参数、输出和进程停止;确认 macOS 是否只有入口识别。 | | diff --git a/rust/lithe-core/src/execution/classpath_jar.rs b/rust/lithe-core/src/execution/classpath_jar.rs new file mode 100644 index 000000000..1d1d5d563 --- /dev/null +++ b/rust/lithe-core/src/execution/classpath_jar.rs @@ -0,0 +1,577 @@ +//! Shortens an oversized Java 8 class path through a manifest-only JAR. +//! +//! JDK 9 added `java @file` argument files, so [`super::plan_launch_command`] +//! cannot help a JDK 8 launch. The launcher has always accepted a JAR whose +//! `META-INF/MANIFEST.MF` lists further class-path entries in its `Class-Path` +//! attribute. Passing that one JAR with `-cp` keeps the command line short +//! while the application class loader still sees every original entry in order. +//! +//! Core converts each entry to an absolute `file:` URL and renders the manifest +//! text, including the 72-byte line rule. The host owns the filesystem: it +//! answers whether an entry is a directory, writes the JAR at the path it +//! supplied, and deletes it after that execution exits. Every URL is +//! percent-encoded UTF-8, so the manifest is pure ASCII and, unlike an argument +//! file, does not depend on the Windows system code page. + +use super::launch_command::{command_line_length, COMMAND_LINE_MARGIN, WINDOWS_COMMAND_LINE_LIMIT}; +use std::path::Path; + +/// First JDK release whose launcher reads argument files; from this version on +/// the argument-file planner owns shortening. +const FIRST_ARGFILE_JAVA_VERSION: u32 = 9; + +/// Launcher options that a JDK 8 `java` accepts for the class path. The long +/// `--class-path` spelling only exists from JDK 9 on. +const CLASSPATH_OPTIONS: &[&str] = &["-cp", "-classpath"]; + +/// Longest manifest line in bytes, excluding the line break. Longer headers +/// continue on lines that start with one space (JAR File Specification). +const MANIFEST_LINE_LIMIT: usize = 72; + +/// Path syntax of the host that will start the JVM. It decides the class-path +/// separator and how absolute paths are recognized and turned into URLs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClasspathStyle { + /// `;`-separated entries with drive-letter or UNC absolute paths. + Windows, + /// `:`-separated entries with `/`-rooted absolute paths. + Posix, +} + +/// Inputs for [`plan_classpath_jar_launch`]. +#[derive(Debug, Clone, Copy)] +pub struct ClasspathJarRequest<'a> { + /// Java executable path used for launcher detection. + pub executable: &'a str, + /// Complete argument vector before shortening. + pub arguments: &'a [String], + /// Directory the JVM starts in. Relative class-path entries resolve against + /// it on the command line, but against the JAR inside a manifest, so Core + /// makes them absolute first. + pub working_directory: &'a str, + /// Host-owned path that may be referenced by a classpath-JAR plan. + pub classpath_jar_path: &'a Path, + /// Optional host-specific command-line cap; `None` uses the Windows cap. + pub limit: Option, + /// JDK feature version read by the host from its `release` file. + pub java_feature_version: Option, + /// Path syntax used by the class-path value. + pub style: ClasspathStyle, +} + +/// How a host should start a JDK 8 launch whose class path may be too long. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClasspathJarPlan { + /// Spawn with the original arguments. + Direct, + /// Write a JAR containing only `META-INF/MANIFEST.MF` with `manifest` at the + /// supplied path, then spawn with `arguments`. + ClasspathJar { + /// Arguments whose effective class-path value is the JAR path. + arguments: Vec, + /// Complete ASCII manifest text with CRLF line breaks. + manifest: String, + }, +} + +/// Plans a manifest-JAR launch for a known JDK older than 9. +/// +/// `is_directory` receives each absolute entry and reports whether it names a +/// directory: a manifest URL must end in `/` for the class loader to treat it as +/// a directory rather than an archive. Returns [`ClasspathJarPlan::Direct`] when +/// the JDK is unknown or supports argument files, the command fits, the launch +/// uses `-jar` (which ignores `-cp`), or an entry cannot be expressed as an +/// absolute URL without changing its meaning, such as a `lib/*` wildcard. +pub fn plan_classpath_jar_launch( + request: ClasspathJarRequest<'_>, + is_directory: impl Fn(&str) -> bool, +) -> ClasspathJarPlan { + let executable_name = request + .executable + .rsplit(['/', '\\']) + .next() + .unwrap_or(request.executable); + if !["java", "java.exe", "javaw", "javaw.exe"] + .iter() + .any(|name| executable_name.eq_ignore_ascii_case(name)) + || !request + .java_feature_version + .is_some_and(|version| version < FIRST_ARGFILE_JAVA_VERSION) + { + return ClasspathJarPlan::Direct; + } + let budget = request + .limit + .unwrap_or(WINDOWS_COMMAND_LINE_LIMIT) + .saturating_sub(COMMAND_LINE_MARGIN); + if command_line_length(request.executable, request.arguments) <= budget { + return ClasspathJarPlan::Direct; + } + let Some(value_index) = effective_classpath_value_index(request.arguments) else { + return ClasspathJarPlan::Direct; + }; + let Some(urls) = classpath_urls( + &request.arguments[value_index], + request.working_directory, + request.style, + &is_directory, + ) else { + return ClasspathJarPlan::Direct; + }; + let mut arguments = request.arguments.to_vec(); + arguments[value_index] = request.classpath_jar_path.display().to_string(); + let mut manifest = String::from("Manifest-Version: 1.0\r\n"); + manifest.push_str(&manifest_header("Class-Path", &urls.join(" "))); + // A header is only recognized once its line is terminated, and the blank + // line closes the main section. + manifest.push_str("\r\n"); + ClasspathJarPlan::ClasspathJar { + arguments, + manifest, + } +} + +/// Finds the class-path value the JDK 8 launcher will use. +/// +/// Launcher options end at the main class or `-jar`. When `-cp` appears more +/// than once the last occurrence wins, so only that value is replaced. `-jar` +/// ignores `-cp` entirely, which leaves nothing to shorten. +fn effective_classpath_value_index(arguments: &[String]) -> Option { + let mut effective = None; + let mut index = 0; + while index < arguments.len() { + let argument = arguments[index].as_str(); + if argument == "-jar" { + return None; + } + if !argument.starts_with('-') { + break; + } + if CLASSPATH_OPTIONS.contains(&argument) && index + 1 < arguments.len() { + effective = Some(index + 1); + index += 2; + } else { + index += 1; + } + } + effective +} + +/// Converts a joined class-path value into manifest URLs in launcher order. +fn classpath_urls( + value: &str, + working_directory: &str, + style: ClasspathStyle, + is_directory: &dyn Fn(&str) -> bool, +) -> Option> { + let separator = match style { + ClasspathStyle::Windows => ';', + ClasspathStyle::Posix => ':', + }; + value + .split(separator) + .map(|entry| { + // The launcher reads an empty element as the current directory. + let entry = if entry.is_empty() { "." } else { entry }; + let absolute = absolute_entry(entry, working_directory, style)?; + // Launcher wildcards are expanded only on the command line; the + // manifest class loader would look for a file literally named `*`. + if absolute.rsplit(['/', '\\']).next() == Some("*") { + return None; + } + Some(file_url(&absolute, style, is_directory(&absolute))) + }) + .collect() +} + +/// Makes one entry absolute, or returns `None` when its base is ambiguous. +fn absolute_entry(entry: &str, working_directory: &str, style: ClasspathStyle) -> Option { + match style { + ClasspathStyle::Posix => { + if entry.starts_with('/') { + Some(entry.to_string()) + } else if working_directory.starts_with('/') { + Some(format!( + "{}/{entry}", + working_directory.trim_end_matches('/') + )) + } else { + None + } + } + ClasspathStyle::Windows => { + let entry = entry.replace('/', "\\"); + let path = if windows_root(&entry).is_some() { + entry + } else if entry.starts_with('\\') { + // `\lib\a.jar` is rooted on the working directory's drive. + format!( + "{}{entry}", + windows_root(working_directory)?.trim_end_matches('\\') + ) + } else if entry.as_bytes().get(1) == Some(&b':') { + // `C:lib` depends on a per-drive current directory that the + // host never sees, so it cannot be resolved deterministically. + return None; + } else { + windows_root(working_directory)?; + format!( + "{}\\{entry}", + working_directory.trim_end_matches(['\\', '/']) + ) + }; + Some(normalize_windows_path(&path)) + } + } +} + +/// Returns the root prefix of an absolute Windows path: `C:\` or `\\host\share`. +fn windows_root(path: &str) -> Option { + let path = path.replace('/', "\\"); + let bytes = path.as_bytes(); + if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' { + return Some(path[..3].to_string()); + } + let unc = path.strip_prefix("\\\\")?; + let mut parts = unc.splitn(3, '\\'); + let host = parts.next().filter(|part| !part.is_empty())?; + let share = parts.next().filter(|part| !part.is_empty())?; + Some(format!("\\\\{host}\\{share}")) +} + +/// Removes `.` and `..` segments the way Windows does, which is lexical. +fn normalize_windows_path(path: &str) -> String { + let root = windows_root(path).expect("absolute Windows path"); + let mut segments: Vec<&str> = Vec::new(); + for segment in path[root.len()..].split('\\') { + match segment { + "" | "." => {} + ".." => { + segments.pop(); + } + _ => segments.push(segment), + } + } + let root = root.trim_end_matches('\\'); + if segments.is_empty() { + format!("{root}\\") + } else { + format!("{root}\\{}", segments.join("\\")) + } +} + +/// Renders an absolute path as a `file:` URL the JDK class loader can open. +/// +/// Drive paths become `file:/C:/...` and UNC paths `file:////host/share/...`, +/// matching `java.io.File.toURI`. Every byte outside a small unreserved set is +/// percent-encoded as UTF-8; the JDK decodes the URL before opening the file. +fn file_url(path: &str, style: ClasspathStyle, directory: bool) -> String { + let path = match style { + ClasspathStyle::Windows => { + let slashed = path.replace('\\', "/"); + if slashed.starts_with("//") { + format!("//{slashed}") + } else { + format!("/{slashed}") + } + } + ClasspathStyle::Posix => path.to_string(), + }; + let mut url = String::from("file:"); + for byte in path.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':') { + url.push(byte as char); + } else { + url.push_str(&format!("%{byte:02X}")); + } + } + if directory && !url.ends_with('/') { + url.push('/'); + } + url +} + +/// Renders one manifest header, continuing lines after 72 bytes. +/// +/// The text is ASCII because every URL is percent-encoded, so splitting at a +/// byte offset can never cut a character in half. +fn manifest_header(name: &str, value: &str) -> String { + let line = format!("{name}: {value}"); + debug_assert!(line.is_ascii()); + let mut rendered = String::with_capacity(line.len() + line.len() / 35 + 2); + let mut rest = line.as_str(); + let mut width = MANIFEST_LINE_LIMIT; + loop { + let (head, tail) = rest.split_at(rest.len().min(width)); + rendered.push_str(head); + rendered.push_str("\r\n"); + if tail.is_empty() { + return rendered; + } + rendered.push(' '); + rest = tail; + width = MANIFEST_LINE_LIMIT - 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn jar_path() -> &'static Path { + Path::new("C:\\Temp\\lithe-run\\launch-1-0.classpath.jar") + } + + /// Mirrors a resolved Maven runtime classpath that exceeds the Windows cap. + fn classpath(entries: usize) -> String { + (0..entries) + .map(|index| { + format!( + "C:\\Users\\developer\\.m2\\repository\\org\\example\\artifact-{index}\\1.0.{index}\\artifact-{index}-1.0.{index}.jar" + ) + }) + .collect::>() + .join(";") + } + + fn request<'a>(arguments: &'a [String], version: Option) -> ClasspathJarRequest<'a> { + ClasspathJarRequest { + executable: "C:\\jdk8\\bin\\java.exe", + arguments, + working_directory: "C:\\work\\demo", + classpath_jar_path: jar_path(), + limit: None, + java_feature_version: version, + style: ClasspathStyle::Windows, + } + } + + /// Joins continuation lines back into logical headers. + fn class_path_urls(manifest: &str) -> Vec { + let logical = manifest.replace("\r\n ", ""); + logical + .split("\r\n") + .find_map(|line| line.strip_prefix("Class-Path: ")) + .expect("manifest has a Class-Path header") + .split(' ') + .map(str::to_string) + .collect() + } + + fn no_directories(_: &str) -> bool { + false + } + + /// Regression for #955: a large JDK 8 project failed in `CreateProcessW` + /// because argument files only exist from JDK 9 on. + #[test] + fn an_oversized_jdk8_classpath_moves_into_a_manifest_jar() { + let arguments = vec![ + "-Dfile.encoding=UTF-8".to_string(), + "-cp".to_string(), + classpath(500), + "com.example.Main".to_string(), + "--server.port=8080".to_string(), + ]; + let ClasspathJarPlan::ClasspathJar { + arguments: shortened, + manifest, + } = plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("an oversized JDK 8 classpath must be shortened"); + }; + assert_eq!( + shortened, + [ + "-Dfile.encoding=UTF-8", + "-cp", + "C:\\Temp\\lithe-run\\launch-1-0.classpath.jar", + "com.example.Main", + "--server.port=8080", + ] + ); + assert!(manifest.starts_with("Manifest-Version: 1.0\r\nClass-Path: file:/C:/Users/")); + assert!(manifest.ends_with("\r\n\r\n")); + let urls = class_path_urls(&manifest); + assert_eq!(urls.len(), 500); + assert_eq!( + urls[0], + "file:/C:/Users/developer/.m2/repository/org/example/artifact-0/1.0.0/artifact-0-1.0.0.jar" + ); + } + + #[test] + fn manifest_lines_never_exceed_72_bytes() { + let arguments = vec!["-cp".into(), classpath(500), "Main".into()]; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("classpath should move"); + }; + let body = manifest + .strip_suffix("\r\n\r\n") + .expect("terminated section"); + let lines: Vec<&str> = body.split("\r\n").collect(); + assert!(lines.len() > 100, "a long header must wrap"); + assert!(lines.iter().all(|line| line.len() <= MANIFEST_LINE_LIMIT)); + // Continuation lines start with the one marker space and fill the + // remaining 71 bytes, except the last one. The content after the + // marker may itself begin with the space that separates two URLs. + for line in &lines[2..lines.len() - 1] { + assert_eq!(line.len(), MANIFEST_LINE_LIMIT); + assert!(line.starts_with(' ')); + } + let urls = class_path_urls(&manifest); + assert_eq!(urls.len(), 500); + assert!(urls.iter().all(|url| url.starts_with("file:/C:/Users/"))); + // Every break is CRLF, the form the `jar` tool writes. + assert_eq!( + manifest.matches('\n').count(), + manifest.matches("\r\n").count() + ); + } + + #[test] + fn spaces_chinese_and_reserved_characters_are_percent_encoded() { + let entries = format!( + "C:\\Program Files\\示例 库\\a#b%c+d.jar;D:/mixed/slash.jar;{}", + classpath(500) + ); + let arguments = vec!["-cp".into(), entries, "Main".into()]; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("classpath should move"); + }; + assert!( + manifest.is_ascii(), + "manifest must not depend on a code page" + ); + let urls = class_path_urls(&manifest); + assert_eq!( + urls[0], + "file:/C:/Program%20Files/%E7%A4%BA%E4%BE%8B%20%E5%BA%93/a%23b%25c%2Bd.jar" + ); + assert_eq!(urls[1], "file:/D:/mixed/slash.jar"); + } + + #[test] + fn directories_relative_unc_and_empty_entries_become_absolute_urls() { + let entries = format!( + "target\\classes;..\\shared\\lib.jar;\\tools\\x.jar;\\\\server\\share\\dep.jar;;{}", + classpath(500) + ); + let arguments = vec!["-cp".into(), entries, "Main".into()]; + let directories = ["C:\\work\\demo\\target\\classes", "C:\\work\\demo"]; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(request(&arguments, Some(8)), |path| { + directories.contains(&path) + }) + else { + panic!("classpath should move"); + }; + let urls = class_path_urls(&manifest); + assert_eq!(urls[0], "file:/C:/work/demo/target/classes/"); + assert_eq!(urls[1], "file:/C:/work/shared/lib.jar"); + assert_eq!(urls[2], "file:/C:/tools/x.jar"); + assert_eq!(urls[3], "file:////server/share/dep.jar"); + // An empty element means the working directory to the launcher. + assert_eq!(urls[4], "file:/C:/work/demo/"); + } + + #[test] + fn jdk9_unknown_short_and_non_java_launches_stay_direct() { + let long = vec!["-cp".into(), classpath(500), "Main".into()]; + for version in [None, Some(9), Some(21)] { + assert_eq!( + plan_classpath_jar_launch(request(&long, version), no_directories), + ClasspathJarPlan::Direct + ); + } + let short = vec!["-cp".into(), classpath(3), "Main".into()]; + assert_eq!( + plan_classpath_jar_launch(request(&short, Some(8)), no_directories), + ClasspathJarPlan::Direct + ); + let mut node = request(&long, Some(8)); + node.executable = "node.exe"; + assert_eq!( + plan_classpath_jar_launch(node, no_directories), + ClasspathJarPlan::Direct + ); + } + + #[test] + fn launches_whose_meaning_a_manifest_cannot_preserve_stay_direct() { + for (entries, target) in [ + (format!("lib\\*;{}", classpath(500)), "Main"), + (format!("C:lib\\a.jar;{}", classpath(500)), "Main"), + (classpath(500), "-jar"), + ] { + let arguments = vec!["-cp".into(), entries, target.into(), "app.jar".into()]; + assert_eq!( + plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories), + ClasspathJarPlan::Direct, + "{target}" + ); + } + let without_classpath = vec!["Main".into(), classpath(500)]; + assert_eq!( + plan_classpath_jar_launch(request(&without_classpath, Some(8)), no_directories), + ClasspathJarPlan::Direct + ); + } + + #[test] + fn only_the_effective_classpath_before_the_main_class_is_replaced() { + let arguments = vec![ + "-cp".into(), + "ignored.jar".into(), + "-classpath".into(), + classpath(500), + "Main".into(), + "-cp".into(), + "program-value".into(), + ]; + let ClasspathJarPlan::ClasspathJar { + arguments: shortened, + manifest, + } = plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("classpath should move"); + }; + assert_eq!(&shortened[..3], &arguments[..3]); + assert_eq!( + shortened[3], + "C:\\Temp\\lithe-run\\launch-1-0.classpath.jar" + ); + assert_eq!(&shortened[4..], &arguments[4..]); + assert!(!manifest.contains("program-value")); + assert!(!manifest.contains("ignored.jar")); + } + + #[test] + fn posix_paths_use_colon_separators() { + let entries = (0..800) + .map(|index| format!("/home/dev/.m2/repository/lib {index}/artifact-{index}.jar")) + .chain(["classes".to_string()]) + .collect::>() + .join(":"); + let arguments = vec!["-cp".into(), entries, "Main".into()]; + let mut posix = request(&arguments, Some(8)); + posix.executable = "/opt/jdk8/bin/java"; + posix.working_directory = "/work/demo"; + posix.style = ClasspathStyle::Posix; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(posix, |path| path == "/work/demo/classes") + else { + panic!("classpath should move"); + }; + let urls = class_path_urls(&manifest); + assert_eq!( + urls[0], + "file:/home/dev/.m2/repository/lib%200/artifact-0.jar" + ); + assert_eq!(urls[800], "file:/work/demo/classes/"); + } +} diff --git a/rust/lithe-core/src/execution/launch_command.rs b/rust/lithe-core/src/execution/launch_command.rs index 148a22929..24eb39c13 100644 --- a/rust/lithe-core/src/execution/launch_command.rs +++ b/rust/lithe-core/src/execution/launch_command.rs @@ -12,6 +12,9 @@ //! deletes it after that execution exits. The returned text is Unicode; the //! host must encode it losslessly with the launcher's native platform encoding. //! JEP 400 does not make Windows launcher argument files UTF-8. +//! +//! JDK 8 has no argument files; [`super::plan_classpath_jar_launch`] covers +//! that release with a manifest-only class-path JAR under the same budget. use serde::{Deserialize, Serialize}; use std::path::Path; @@ -23,7 +26,7 @@ pub const WINDOWS_COMMAND_LINE_LIMIT: usize = 32_767; /// Headroom for the null terminator and for host quoting that this estimate /// does not model exactly. Shortening early costs nothing; shortening too late /// fails the launch. -const COMMAND_LINE_MARGIN: usize = 2_048; +pub(super) const COMMAND_LINE_MARGIN: usize = 2_048; /// Options whose value is a joined list of absolute paths. These carry /// practically all of an oversized command line, so moving them into the @@ -226,7 +229,7 @@ pub fn plan_launch_command( /// Windows counts UTF-16 code units, and each argument containing whitespace or /// a quote is wrapped in quotes. The estimate never has to be exact because /// [`COMMAND_LINE_MARGIN`] absorbs the difference. -fn command_line_length(executable: &str, arguments: &[String]) -> usize { +pub(super) fn command_line_length(executable: &str, arguments: &[String]) -> usize { let mut length = quoted_length(executable); for argument in arguments { length += 1 + quoted_length(argument); diff --git a/rust/lithe-core/src/execution/mod.rs b/rust/lithe-core/src/execution/mod.rs index 2c75486be..b3cbcb0a3 100644 --- a/rust/lithe-core/src/execution/mod.rs +++ b/rust/lithe-core/src/execution/mod.rs @@ -1,5 +1,6 @@ //! Run configuration generation, resolution, and project detectors. +mod classpath_jar; mod configuration; mod detectors; mod java_selection; @@ -10,6 +11,9 @@ pub use java_selection::{ }; mod types; +pub use classpath_jar::{ + plan_classpath_jar_launch, ClasspathJarPlan, ClasspathJarRequest, ClasspathStyle, +}; pub(crate) use configuration::*; pub use launch_command::{ java_feature_version_from_release, plan_launch_command, plan_launch_command_request, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index d31596308..b58f8c74b 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -38,6 +38,18 @@ quoted values, since the native argument-file parser processes bytes. Every execution owns an exclusively created temporary file; partial writes and spawn failures clean it up, while successful launches retain it until that exact process exits. A replacement execution never shares its predecessor's file. +For a known JDK older than 9, Rust hosts call +`lithe_core::execution::plan_classpath_jar_launch` instead. Under the same +command-line budget it replaces the effective `-cp`/`-classpath` value with a +host-owned JAR path and returns the ASCII `META-INF/MANIFEST.MF` text: the +`Class-Path` header lists every entry, in order, as an absolute percent-encoded +UTF-8 `file:` URL (directories end in `/`), wrapped at 72 bytes. The host +answers whether each entry is a directory and writes the manifest-only JAR with +the same exclusive temporary-file lifecycle. Wildcard entries, drive-relative +Windows entries, `-jar` launches, and unknown JDK versions stay direct. This is +a Rust API only; there is no JSON command yet because the macOS process +argument limit (`ARG_MAX`) is far above the Windows cap, so a direct JDK 8 +launch already succeeds there. Strings returned by the core are UTF-8 JSON allocated by Rust. The caller must release response strings with `lithe_core_free_string`. diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 08f610fe4..7e48c3f5b 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1637,13 +1637,14 @@ "windows": { "evidence": [ "windows/tauri/src-tauri/src/run/launch_arguments.rs", - "rust/lithe-core/src/execution/launch_command.rs" + "rust/lithe-core/src/execution/launch_command.rs", + "rust/lithe-core/src/execution/classpath_jar.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Run", - "verification": "使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。" + "verification": "使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。" }, { "id": "debug-breakpoints", diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 825fec42a..47ff0e1ec 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -676,7 +676,7 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S fn prepare_launch_arguments( args: &StartProcessArgs, ) -> Result<(Vec, Option), String> { - launch_arguments::prepare(&args.executable, &args.arguments) + launch_arguments::prepare(&args.executable, &args.arguments, &args.working_directory) } /// Explains a refused spawn with the detail the operating system reported. diff --git a/windows/tauri/src-tauri/src/run/launch_arguments.rs b/windows/tauri/src-tauri/src/run/launch_arguments.rs index 2425fd932..02c4a9720 100644 --- a/windows/tauri/src-tauri/src/run/launch_arguments.rs +++ b/windows/tauri/src-tauri/src/run/launch_arguments.rs @@ -1,4 +1,8 @@ //! Native encoding and execution-owned temporary files for Java launch arguments. +//! +//! JDK 9+ launches use a Core-planned `@argfile`; JDK 8 launches use a +//! Core-planned manifest-only class-path JAR. Both files are created under the +//! system temporary directory and owned by [`LaunchArgumentFile`]. use std::fs::{self, OpenOptions}; use std::io::Write; @@ -21,12 +25,17 @@ impl Drop for LaunchArgumentFile { pub(super) fn prepare( executable: &str, arguments: &[String], + working_directory: &str, ) -> Result<(Vec, Option), String> { let version = java_feature_version(executable); - // The planner also checks executable identity and requires a known JDK >= 9. - // No temporary file is created for a direct launch. + // JDK 8 has no `@argfile`; Core plans a manifest-only class-path JAR for + // it instead. Both planners check executable identity, and an unknown + // version keeps the original command. + if version.is_some_and(|version| version < 9) { + return prepare_classpath_jar(executable, arguments, working_directory, version); + } for _ in 0..128 { - let path = next_argfile_path(); + let path = next_temporary_path("argfile"); let lithe_core::execution::LaunchCommandPlan::Argfile { arguments: shortened, argfile_contents, @@ -56,10 +65,81 @@ pub(super) fn prepare( Err("Could not allocate a unique Java launch argument file. Retry the launch.".into()) } -fn next_argfile_path() -> PathBuf { +/// Writes the JDK 8 class-path JAR planned by Core. +/// +/// The JAR lives in the same per-execution temporary directory as argument +/// files, never next to the JDK or the installation, and the returned owner +/// deletes it on write failure, spawn failure, or process exit. +fn prepare_classpath_jar( + executable: &str, + arguments: &[String], + working_directory: &str, + version: Option, +) -> Result<(Vec, Option), String> { + use lithe_core::execution::{ + plan_classpath_jar_launch, ClasspathJarPlan, ClasspathJarRequest, ClasspathStyle, + }; + let style = if cfg!(windows) { + ClasspathStyle::Windows + } else { + ClasspathStyle::Posix + }; + for _ in 0..128 { + let path = next_temporary_path("classpath.jar"); + let ClasspathJarPlan::ClasspathJar { + arguments: shortened, + manifest, + } = plan_classpath_jar_launch( + ClasspathJarRequest { + executable, + arguments, + working_directory, + classpath_jar_path: &path, + limit: None, + java_feature_version: version, + style, + }, + |entry| std::path::Path::new(entry).is_dir(), + ) + else { + return Ok((arguments.to_vec(), None)); + }; + fs::create_dir_all(path.parent().expect("temporary file has a parent")) + .map_err(|error| format!("Could not create Java class-path JAR directory: {error}"))?; + let file = match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(format!("Could not create Java class-path JAR: {error}")), + }; + let owner = LaunchArgumentFile(path); + write_manifest_jar(file, &manifest) + .map_err(|error| format!("Could not write Java class-path JAR: {error}"))?; + return Ok((shortened, Some(owner))); + } + Err("Could not allocate a unique Java class-path JAR. Retry the launch.".into()) +} + +/// Writes a JAR whose only entry is `META-INF/MANIFEST.MF`. +/// +/// The manifest is stored uncompressed: it is small, and stored entries keep +/// the archive readable by every JDK 8 `ZipFile` implementation. +fn write_manifest_jar(file: fs::File, manifest: &str) -> std::io::Result<()> { + let mut writer = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + writer + .start_file("META-INF/MANIFEST.MF", options) + .map_err(std::io::Error::other)?; + writer.write_all(manifest.as_bytes())?; + // `finish` returns the file so it is closed before the owner can delete it. + let file = writer.finish().map_err(std::io::Error::other)?; + file.sync_all() +} + +fn next_temporary_path(extension: &str) -> PathBuf { static NEXT_ID: AtomicU64 = AtomicU64::new(0); std::env::temp_dir().join("lithe-run").join(format!( - "launch-{}-{}.argfile", + "launch-{}-{}.{extension}", std::process::id(), NEXT_ID.fetch_add(1, Ordering::Relaxed) )) @@ -195,12 +275,22 @@ mod tests { struct JdkFixture(PathBuf); impl JdkFixture { fn new() -> Self { - let root = next_argfile_path().with_extension("jdk"); + Self::with_version("21.0.2") + } + fn with_version(version: &str) -> Self { + let root = next_temporary_path("jdk"); fs::create_dir_all(root.join("bin")).unwrap(); let fixture = Self(root); - fs::write(fixture.0.join("release"), "JAVA_VERSION=\"21.0.2\"\n").unwrap(); + fs::write( + fixture.0.join("release"), + format!("JAVA_VERSION=\"{version}\"\n"), + ) + .unwrap(); fixture } + fn working_directory(&self) -> String { + self.0.to_string_lossy().into_owned() + } fn executable(&self) -> String { self.0.join("bin/java.exe").to_string_lossy().into_owned() } @@ -221,11 +311,104 @@ mod tests { ] } + /// A host-native class path under the fixture: the separator and absolute + /// path syntax follow the platform the planner will be asked about. + fn native_long_classpath(jdk: &JdkFixture, classes_name: &str) -> (Vec, PathBuf) { + let classes = jdk.0.join(classes_name); + fs::create_dir_all(&classes).unwrap(); + let separator = if cfg!(windows) { ";" } else { ":" }; + let entries = std::iter::once(classes.to_string_lossy().into_owned()) + .chain((0..600).map(|i| { + jdk.0 + .join(format!("repository/artifact-{i}/1.0/artifact-{i}.jar")) + .to_string_lossy() + .into_owned() + })) + .collect::>() + .join(separator); + (vec!["-cp".into(), entries, "Main".into()], classes) + } + + fn read_manifest(path: &std::path::Path) -> String { + let mut archive = zip::ZipArchive::new(fs::File::open(path).unwrap()).unwrap(); + assert_eq!(archive.len(), 1, "the JAR holds only its manifest"); + let mut manifest = String::new(); + std::io::Read::read_to_string( + &mut archive.by_name("META-INF/MANIFEST.MF").unwrap(), + &mut manifest, + ) + .unwrap(); + manifest + } + + /// Regression for #955: JDK 8 cannot read `@argfile`, so its oversized + /// class path must be written to a temporary manifest JAR instead. + #[test] + fn jdk8_launch_uses_an_owned_classpath_jar_in_the_temporary_directory() { + let jdk = JdkFixture::with_version("1.8.0_392"); + // The manifest percent-encodes UTF-8, so this name works on every + // Windows code page, unlike an argument file. + let (arguments, classes) = native_long_classpath(&jdk, "项目 classes"); + let (shortened, file) = + prepare(&jdk.executable(), &arguments, &jdk.working_directory()).unwrap(); + let file = file.expect("an oversized JDK 8 class path must be shortened"); + let jar = file.0.clone(); + assert!(jar.starts_with(std::env::temp_dir().join("lithe-run"))); + assert!(jar.to_string_lossy().ends_with(".classpath.jar")); + assert_eq!(shortened, ["-cp", jar.to_str().unwrap(), "Main"]); + let manifest = read_manifest(&jar); + assert!(manifest.starts_with("Manifest-Version: 1.0\r\nClass-Path: file:")); + let logical = manifest.replace("\r\n ", ""); + let class_path = logical + .lines() + .find_map(|line| line.strip_prefix("Class-Path: ")) + .unwrap(); + let urls: Vec<&str> = class_path.split(' ').collect(); + assert_eq!(urls.len(), 601); + // The existing directory keeps its trailing slash and its encoded name. + assert!( + urls[0].ends_with("/%E9%A1%B9%E7%9B%AE%20classes/"), + "{}", + urls[0] + ); + assert!(urls[1].ends_with("/artifact-0.jar")); + drop(file); + assert!(!jar.exists(), "the owner deletes the JAR"); + assert!(classes.exists(), "only the temporary JAR is removed"); + } + + #[test] + fn jdk9_and_later_still_use_an_argument_file() { + let jdk = JdkFixture::with_version("17.0.9"); + let (arguments, _) = native_long_classpath(&jdk, "classes"); + let (shortened, file) = + prepare(&jdk.executable(), &arguments, &jdk.working_directory()).unwrap(); + let file = file.expect("an oversized JDK 17 class path must be shortened"); + assert!(file.0.to_string_lossy().ends_with(".argfile")); + assert_eq!(shortened[0], format!("@{}", file.0.display())); + } + + #[test] + fn short_and_unknown_version_jdk8_launches_create_no_file() { + let jdk = JdkFixture::with_version("1.8.0_392"); + let short = vec!["-cp".into(), "classes".into(), "Main".into()]; + let (actual, file) = prepare(&jdk.executable(), &short, &jdk.working_directory()).unwrap(); + assert_eq!(actual, short); + assert!(file.is_none()); + fs::write(jdk.0.join("release"), "MODULES=\"java.base\"\n").unwrap(); + let (arguments, _) = native_long_classpath(&jdk, "classes"); + let (actual, file) = + prepare(&jdk.executable(), &arguments, &jdk.working_directory()).unwrap(); + assert_eq!(actual, arguments); + assert!(file.is_none()); + } + #[test] fn replacement_execution_owns_a_distinct_file() { let jdk = JdkFixture::new(); - let (first_args, first) = prepare(&jdk.executable(), &long_arguments()).unwrap(); - let (second_args, second) = prepare(&jdk.executable(), &long_arguments()).unwrap(); + let cwd = jdk.working_directory(); + let (first_args, first) = prepare(&jdk.executable(), &long_arguments(), &cwd).unwrap(); + let (second_args, second) = prepare(&jdk.executable(), &long_arguments(), &cwd).unwrap(); let first = first.unwrap(); let second = second.unwrap(); assert_ne!(first_args, second_args); @@ -244,12 +427,13 @@ mod tests { fn short_non_java_and_unknown_jdk_launches_stay_direct() { let jdk = JdkFixture::new(); let short = vec!["-cp".into(), "classes".into(), "Main".into()]; - let (actual, file) = prepare(&jdk.executable(), &short).unwrap(); + let cwd = jdk.working_directory(); + let (actual, file) = prepare(&jdk.executable(), &short, &cwd).unwrap(); assert_eq!(actual, short); assert!(file.is_none()); for executable in ["node.exe", "C:/missing/bin/java.exe"] { let args = long_arguments(); - let (actual, file) = prepare(executable, &args).unwrap(); + let (actual, file) = prepare(executable, &args, &cwd).unwrap(); assert_eq!(actual, args); assert!(file.is_none()); } From b41b7c8b8a990c869c8c14fde1b6b663596d2a67 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 1 Oct 2026 15:52:20 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(core):=20classpath=20JAR=20=E8=A7=84?= =?UTF-8?q?=E5=88=92=E6=AD=A3=E7=A1=AE=E5=A4=84=E7=90=86=20Windows=20?= =?UTF-8?q?=E9=95=BF=E8=B7=AF=E5=BE=84=E5=89=8D=E7=BC=80=20(#955)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自审发现 \\?\C:\... 和 \\?\UNC\... 形式的条目或工作目录会被当成主机名 为 "?" 的 UNC 路径,生成错误的 file: URL。现在先还原为普通盘符/UNC 路径;其他 \\?\ 与 \\.\ 设备路径无法表示为 file: URL,保持原命令启动。 Co-Authored-By: Claude --- .../lithe-core/src/execution/classpath_jar.rs | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/rust/lithe-core/src/execution/classpath_jar.rs b/rust/lithe-core/src/execution/classpath_jar.rs index 1d1d5d563..fafacf933 100644 --- a/rust/lithe-core/src/execution/classpath_jar.rs +++ b/rust/lithe-core/src/execution/classpath_jar.rs @@ -200,7 +200,9 @@ fn absolute_entry(entry: &str, working_directory: &str, style: ClasspathStyle) - } } ClasspathStyle::Windows => { - let entry = entry.replace('/', "\\"); + let entry = strip_verbatim_prefix(&entry.replace('/', "\\"))?; + let working_directory = strip_verbatim_prefix(&working_directory.replace('/', "\\"))?; + let working_directory = working_directory.as_str(); let path = if windows_root(&entry).is_some() { entry } else if entry.starts_with('\\') { @@ -225,6 +227,29 @@ fn absolute_entry(entry: &str, working_directory: &str, style: ClasspathStyle) - } } +/// Rewrites `\\?\C:\x` to `C:\x` and `\\?\UNC\host\share` to `\\host\share`. +/// +/// Other `\\?\` and `\\.\` device paths have no `file:` URL form, so they +/// return `None` and keep the launch direct instead of becoming a bogus UNC +/// host named `?` or `.`. +fn strip_verbatim_prefix(entry: &str) -> Option { + if let Some(rest) = entry.strip_prefix("\\\\?\\UNC\\") { + return Some(format!("\\\\{rest}")); + } + if let Some(rest) = entry.strip_prefix("\\\\?\\") { + let bytes = rest.as_bytes(); + let drive = bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes[2] == b'\\'; + return drive.then(|| rest.to_string()); + } + if entry.starts_with("\\\\.\\") { + return None; + } + Some(entry.to_string()) +} + /// Returns the root prefix of an absolute Windows path: `C:\` or `\\host\share`. fn windows_root(path: &str) -> Option { let path = path.replace('/', "\\"); @@ -458,7 +483,7 @@ mod tests { #[test] fn directories_relative_unc_and_empty_entries_become_absolute_urls() { let entries = format!( - "target\\classes;..\\shared\\lib.jar;\\tools\\x.jar;\\\\server\\share\\dep.jar;;{}", + "target\\classes;..\\shared\\lib.jar;\\tools\\x.jar;\\\\server\\share\\dep.jar;;\\\\?\\C:\\long\\v.jar;\\\\?\\UNC\\server\\share\\w.jar;{}", classpath(500) ); let arguments = vec!["-cp".into(), entries, "Main".into()]; @@ -477,6 +502,9 @@ mod tests { assert_eq!(urls[3], "file:////server/share/dep.jar"); // An empty element means the working directory to the launcher. assert_eq!(urls[4], "file:/C:/work/demo/"); + // Verbatim paths from canonicalized tooling keep their real target. + assert_eq!(urls[5], "file:/C:/long/v.jar"); + assert_eq!(urls[6], "file:////server/share/w.jar"); } #[test] @@ -493,6 +521,19 @@ mod tests { plan_classpath_jar_launch(request(&short, Some(8)), no_directories), ClasspathJarPlan::Direct ); + let relative = vec![ + "-cp".into(), + format!("classes;{}", classpath(500)), + "Main".into(), + ]; + let mut verbatim = request(&relative, Some(8)); + verbatim.working_directory = "\\\\?\\C:\\work\\demo"; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(verbatim, no_directories) + else { + panic!("a verbatim working directory still resolves relative entries"); + }; + assert_eq!(class_path_urls(&manifest)[0], "file:/C:/work/demo/classes"); let mut node = request(&long, Some(8)); node.executable = "node.exe"; assert_eq!( @@ -506,6 +547,7 @@ mod tests { for (entries, target) in [ (format!("lib\\*;{}", classpath(500)), "Main"), (format!("C:lib\\a.jar;{}", classpath(500)), "Main"), + (format!("\\\\.\\pipe\\x;{}", classpath(500)), "Main"), (classpath(500), "-jar"), ] { let arguments = vec!["-cp".into(), entries, target.into(), "app.jar".into()]; From 94efa552533cc5bed7e11b189d438fe3fc3e588a Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 1 Oct 2026 09:26:36 +0000 Subject: [PATCH 3/3] fix(windows): prepare Java launches off the UI thread (#955) Guard pending launches against stop/restart races, preserve execution and window ownership, and clean reservations on failure. Register per-execution Java files as non-reusable resources with tests and lifecycle documentation. --- ...026-09-20-oversized-java-launch-command.md | 13 +- docs/ci-builds.md | 8 + docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- scripts/reuse-worktree-resources.mjs | 6 +- scripts/test-reuse-worktree-resources.mjs | 8 + scripts/worktree-resources.json | 10 + shared/platform-feature-matrix.json | 5 +- windows/tauri/src-tauri/src/run.rs | 236 ++++++++++++++++-- 9 files changed, 257 insertions(+), 33 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md b/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md index a7dfb9c7c..996a213ec 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md +++ b/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md @@ -107,6 +107,16 @@ JDK 8 不认识 argfile,Windows 普通 Run 改用 classpath JAR(只含 `META - **修改 PowerShell profile、注册表或全局环境变量**:被否。该问题只属于 Lithe 生成的输入,系统级修改会影响 IDE 之外的程序, 也不能可靠消除不同 Shell 的长度和解析差异。 +Windows 的目录探测、JDK release 读取、临时文件写入和进程创建在后台阻塞任务中执行, +不能让慢速 UNC 路径冻结 UI。每次启动先登记独立的准备请求,停止会取消该请求, +重新运行会替换它;准备完成后必须在与停止共用的锁内检查归属,再发布进程。 +例如 A 准备期间启动 B,即使 A 最后才完成,也必须释放 A 的临时文件而不启动它。 +不能只把同步命令改成后台执行而省略归属检查,否则停止后旧进程可能重新出现。 + +临时 argfile 与 classpath JAR 在 `scripts/worktree-resources.json` 的 +`java-launch-temporaries` 中登记为不可复用资源,复用脚本直接拒绝。它们属于单次 +执行,没有构建身份 stamp,任何阶段都不能跨工作树复制,也不写发行目录。 + ## 后果 - 大型多模块 Maven 项目在 Windows 上可以启动,命令行只剩下一个 `@文件` 引用。 @@ -116,7 +126,8 @@ JDK 8 不认识 argfile,Windows 普通 Run 改用 classpath JAR(只含 `META - 每次执行用 `create_new` 独占创建一个参数文件,不按窗口或会话名复用。文件由 RAII 所有者管理(离开作用域时自动清理):写入或启动失败时删除,成功后交给 该进程的退出线程删除;旧执行的清理不能影响替代它的新执行。 - JVM 已读取参数后,外部清理文件不影响该进程;后续执行会创建新文件。 + `@argfile` 在 JVM 启动时读取;classpath JAR 可能在后续类加载时仍被访问, + 因此两者统一保留到进程退出,后续执行创建新文件。 - `@argfile` 需要 JDK 9 以上。JDK 8 的普通 Run 现在由 classpath JAR 覆盖。代价: JDK 8 缩短后 `System.getProperty("java.class.path")` 只返回临时 JAR 路径; 只依赖类加载器的代码不受影响。JDK 8 的 `--module-path` 不存在,所以不需要处理。 diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 3ad394f31..5eec402cb 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -176,6 +176,14 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 以下目录不应直接复制或跨工作树共享: +- Java 启动临时文件:`/lithe-run/launch--.argfile` + 和同目录的 `.classpath.jar` 由平台启动 adapter 为单次执行独占创建,包含该次 + 执行的绝对类路径、工作目录、JDK 版本及编码语义,没有可复用的版本、平台、架构 + 或工具链 identity stamp。准备失败、准备完成前已取消、启动失败或进程退出后由 + 所有者删除;系统临时目录由平台解析,不写安装包或 JDK,不影响签名或增量更新。 + `excludedResources.java-launch-temporaries` 经复用脚本的排除路由直接拒绝,任何 + 复制阶段都不得共享;内容哈希相同也不能转移进程所有权。 + - Agent CLI 的用户级安装与下载缓存:npm 的 global prefix/cache、Homebrew 的 Cellar/Caskroom/cache、用户目录下 `.local/share/claude/versions`。它们由运行时 `PATH` 和原安装器决定,不属于工作树;包版本、平台与架构由原安装器校验, diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index af6f05a25..feca386e8 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -61,7 +61,7 @@ lsp-navigation-edits,Java,语言服务,跳转、引用、语义标记与编辑, run-discovery,运行与调试,运行配置,入口点与运行配置发现,已实现,待验证,已实现,待验证,Run,验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。,,macos/Sources/Lithe/Views/Run; shared/fixtures/run-configuration,windows/tauri/src/features/run; shared/fixtures/run-configuration run-save-toolchain,运行与调试,运行配置,保存前同步与工具链解析,已实现,待验证,已实现,待验证,Run,修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java,windows/tauri/src/features/run; windows/tauri/src/features/maven run-java-test,运行与调试,运行配置,Java main 与测试运行,已实现,待验证,已实现,待验证,Run,运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。,,macos/Sources/Lithe/Views/Run; shared/fixtures/debug,windows/tauri/src/features/run; shared/fixtures/debug; windows/tauri/src/features/run/components/run-pane.tsx; windows/tauri/src/features/run/components/run-output-text.tsx -run-java-long-classpath,运行与调试,运行配置,超长 Java 类路径自动缩短,已实现,待验证,已实现,待验证,Run,使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。,,macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift; macos/Sources/LitheExecutionModule/Services/RunService.swift; rust/lithe-core/src/execution/launch_command.rs,windows/tauri/src-tauri/src/run/launch_arguments.rs; rust/lithe-core/src/execution/launch_command.rs; rust/lithe-core/src/execution/classpath_jar.rs +run-java-long-classpath,运行与调试,运行配置,超长 Java 类路径自动缩短,已实现,待验证,已实现,待验证,Run,使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。 在慢速网络盘 classpath 准备期间确认工作台可交互;停止或重新运行后,旧准备结果不得启动进程,旧 executionId 的停止请求不得影响新执行。,,macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift; macos/Sources/LitheExecutionModule/Services/RunService.swift; rust/lithe-core/src/execution/launch_command.rs,windows/tauri/src-tauri/src/run/launch_arguments.rs; rust/lithe-core/src/execution/launch_command.rs; rust/lithe-core/src/execution/classpath_jar.rs; windows/tauri/src-tauri/src/run.rs debug-breakpoints,运行与调试,调试器,启动调试与断点,已实现,待验证,部分实现,待验证,Debug,设置、命中、禁用和重新定位断点,确认调试会话生命周期。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug debug-state,运行与调试,调试器,变量、异常与断开策略,已实现,待验证,部分实现,待验证,Debug,验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug terminal-shell,工作台,终端,Shell 发现与配置,已实现,待验证,已实现,待验证,Terminal,验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效,以及环境变量、工作目录和不可用 Shell 的提示。,,macos/Sources/Lithe/Views/Terminal; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Services,windows/tauri/src/features/terminal; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/lib/default-shell-options.ts; windows/tauri/src-tauri diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index cac928c42..d80efe74d 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -167,7 +167,7 @@ | 运行配置 | **入口点与运行配置发现**
run-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/run-configuration` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/run-configuration` | Run | 验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。 | | | 运行配置 | **保存前同步与工具链解析**
run-save-toolchain | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`windows/tauri/src/features/maven` | Run | 修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。 | | | 运行配置 | **Java main 与测试运行**
run-java-test | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/debug` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/debug`、`windows/tauri/src/features/run/components/run-pane.tsx`、`windows/tauri/src/features/run/components/run-output-text.tsx` | Run | 运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 | | -| 运行配置 | **超长 Java 类路径自动缩短**
run-java-long-classpath | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift`、`macos/Sources/LitheExecutionModule/Services/RunService.swift`、`rust/lithe-core/src/execution/launch_command.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src-tauri/src/run/launch_arguments.rs`、`rust/lithe-core/src/execution/launch_command.rs`、`rust/lithe-core/src/execution/classpath_jar.rs` | Run | 使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。 | | +| 运行配置 | **超长 Java 类路径自动缩短**
run-java-long-classpath | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift`、`macos/Sources/LitheExecutionModule/Services/RunService.swift`、`rust/lithe-core/src/execution/launch_command.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src-tauri/src/run/launch_arguments.rs`、`rust/lithe-core/src/execution/launch_command.rs`、`rust/lithe-core/src/execution/classpath_jar.rs`、`windows/tauri/src-tauri/src/run.rs` | Run | 使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。 在慢速网络盘 classpath 准备期间确认工作台可交互;停止或重新运行后,旧准备结果不得启动进程,旧 executionId 的停止请求不得影响新执行。 | | | 调试器 | **启动调试与断点**
debug-breakpoints | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/debugger`、`shared/fixtures/debug` | Debug | 设置、命中、禁用和重新定位断点,确认调试会话生命周期。 | Windows 真实调试产品链路仍在 #466 跟进。 | | 调试器 | **变量、异常与断开策略**
debug-state | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/debugger`、`shared/fixtures/debug` | Debug | 验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。 | Windows 真实调试产品链路仍在 #466 跟进。 | | 项目运行 | **Docker / Compose 项目识别与运行**
docker-compose | 🟡 部分实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/RunConfigurationIcon.swift`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/docker`、`windows/tauri/src/features/run` | Run / Docker | 使用 Dockerfile 和 Compose fixture 验证识别、命令参数、输出和进程停止;确认 macOS 是否只有入口识别。 | | diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 21badfe7a..69d5700a1 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -366,9 +366,9 @@ async function main() { const selected = options.resources.length > 0 ? options.resources.map((identifier) => { - // Runtime snapshots and isolated PHP packaging resources take this - // rejection route, never a content-hash reuse validator: the pinned npm - // archive alone does not identify the generated, signed plugin package. + // Runtime snapshots, per-execution Java launch files and isolated plugin + // packages take this rejection route, never a content-hash validator. + // Identical bytes do not establish transferable execution ownership. if (excludedResources.some((resource) => resource.id === identifier)) { const excluded = excludedResources.find((resource) => resource.id === identifier); throw new Error(`Resource ${identifier} is isolated (${excluded.locations.join(", ")}): ${excluded.reason}; it cannot be reused across worktrees`); diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 4dd984dcd..77a2cbbde 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -64,6 +64,14 @@ function reuse(extraArguments = []) { } try { + await test("Java launch files are never listed or copied between worktrees", { timeout: 15000 }, () => { + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes("java-launch-temporaries")); + const refused = reuse(["--resource", "java-launch-temporaries"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /java-launch-temporaries.*classpath\.jar.*cannot be reused/); + }); await test("IDE MCP credentials and helpers cannot cross worktrees", { timeout: 15000 }, () => { const refused = reuse(["--resource", "ide-mcp"]); assert.notEqual(refused.status, 0); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 5fce253c3..0e25cd98c 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -117,6 +117,16 @@ ], "identity": "Helper executable is tied to source, Cargo.lock, target triple and signing; runtime connection descriptors contain ephemeral bearer credentials and workspace ownership locks. No reusable build identity stamp.", "reason": "Build helpers separately before packaging; never copy runtime connection files or locks. Installed helpers remain read-only. There is no permitted worktree copy stage." + }, + { + "id": "java-launch-temporaries", + "locations": [ + "/lithe-run/launch--.argfile", + "/lithe-run/launch--.classpath.jar" + ], + "reusable": false, + "identity": "Owned by one Java execution: absolute classpath, working directory, JDK version and native encoding. No reusable version/platform/architecture/toolchain identity stamp.", + "reason": "Created exclusively by the platform launch adapter and deleted on preparation failure, cancelled launch, spawn failure or process exit. Never copy, publish or share between worktrees at any stage; installed bundles and JDK directories remain read-only." } ] } diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 7e48c3f5b..8dc2759e1 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1638,13 +1638,14 @@ "evidence": [ "windows/tauri/src-tauri/src/run/launch_arguments.rs", "rust/lithe-core/src/execution/launch_command.rs", - "rust/lithe-core/src/execution/classpath_jar.rs" + "rust/lithe-core/src/execution/classpath_jar.rs", + "windows/tauri/src-tauri/src/run.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Run", - "verification": "使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。" + "verification": "使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。 在慢速网络盘 classpath 准备期间确认工作台可交互;停止或重新运行后,旧准备结果不得启动进程,旧 executionId 的停止请求不得影响新执行。" }, { "id": "debug-breakpoints", diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 47ff0e1ec..5563672f0 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::{ mpsc::{self, Receiver, RecvTimeoutError, SyncSender}, - Mutex, OnceLock, + Arc, Mutex, OnceLock, }; use std::thread; use std::time::{Duration, Instant}; @@ -79,6 +79,52 @@ fn sessions() -> &'static Mutex> { SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) } +// Serializes reservation changes with process publication, never with launch preparation. +fn pending_launches() -> &'static Mutex> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(HashMap::new())) +} + +struct PendingLaunch { + identity: Arc<()>, + execution_id: Option, +} + +struct LaunchReservation { + key: RunSessionKey, + identity: Arc<()>, +} + +impl LaunchReservation { + fn is_current(&self, pending: &HashMap) -> bool { + pending + .get(&self.key) + .is_some_and(|launch| Arc::ptr_eq(&launch.identity, &self.identity)) + } +} + +impl Drop for LaunchReservation { + fn drop(&mut self) { + if let Ok(mut pending) = pending_launches().lock() { + if self.is_current(&pending) { + pending.remove(&self.key); + } + } + } +} + +fn cancel_pending_launch( + pending: &mut HashMap, + key: &RunSessionKey, + execution_id: Option<&str>, +) { + if pending.get(key).is_some_and(|launch| { + execution_id.is_none() || launch.execution_id.as_deref() == execution_id + }) { + pending.remove(key); + } +} + fn run_session_key(window_label: &str, session_id: &str) -> RunSessionKey { RunSessionKey { window_label: window_label.to_string(), @@ -604,12 +650,52 @@ pub async fn run_execute_prelaunch(args: ExecutePreLaunchArgs) -> Result Result<(), String> { +pub async fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), String> { if args.window_label.trim().is_empty() { return Err("A run process must be started from an active window.".into()); } - stop_session(&args.window_label, &args.session_id, None); + let (reservation, previous_pid) = { + let mut pending = pending_launches() + .lock() + .map_err(|_| "Run launch state is unavailable".to_string())?; + let previous_pid = take_running_pid(&args.window_label, &args.session_id, None); + let key = run_session_key(&args.window_label, &args.session_id); + let identity = Arc::new(()); + pending.insert( + key.clone(), + PendingLaunch { + identity: identity.clone(), + execution_id: args.execution_id.clone(), + }, + ); + (LaunchReservation { key, identity }, previous_pid) + }; + // Filesystem metadata, JAR writes and process creation may block. The worker + // owns the reservation and temporary file even if the awaiting task ends. + tauri::async_runtime::spawn_blocking(move || { + if let Some(pid) = previous_pid { + terminate_run_process(pid); + } + start_reserved_process(app, args, reservation) + }) + .await + .map_err(|error| format!("Run launch preparation failed: {error}"))? +} + +fn start_reserved_process( + app: AppHandle, + args: StartProcessArgs, + reservation: LaunchReservation, +) -> Result<(), String> { let (arguments, argfile) = prepare_launch_arguments(&args)?; + let pending = pending_launches() + .lock() + .map_err(|_| "Run launch state is unavailable".to_string())?; + if !reservation.is_current(&pending) { + return Err("Run launch was stopped or replaced during preparation.".into()); + } + // Stop/restart cannot invalidate the reservation between this check and + // publication in sessions(). Preparation never holds this lock. let mut command = command_for_executable(&args.executable, &arguments); command .current_dir(&args.working_directory) @@ -618,6 +704,9 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S .stdout(Stdio::piped()) .stderr(Stdio::piped()); apply_creation_flags(&mut command); + let mut current = sessions() + .lock() + .map_err(|_| "Run process state is unavailable".to_string())?; let mut child = match command.spawn() { Ok(child) => child, Err(error) => { @@ -631,17 +720,18 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S let stderr = child.stderr.take(); let execution_id = args.execution_id.clone(); let session_key = run_session_key(&args.window_label, &args.session_id); - sessions() - .lock() - .map_err(|_| "Run process state is unavailable".to_string())? - .insert( - session_key, - RunningSession { - pid, - execution_id: args.execution_id, - stdin, - }, - ); + current.insert( + session_key, + RunningSession { + pid, + execution_id: args.execution_id, + stdin, + }, + ); + + drop(current); + drop(pending); + drop(reservation); // Run and Maven panels rebuild highlighted output when this event crosses // into the webview. Coalesce native pipe reads before that expensive @@ -701,12 +791,16 @@ fn spawn_failure_message(executable: &str, arguments: &[String], error: &std::io } #[tauri::command] -pub fn run_stop_process( +pub async fn run_stop_process( window_label: String, session_id: String, execution_id: Option, ) -> Result<(), String> { - stop_session(&window_label, &session_id, execution_id.as_deref()); + if let Some(pid) = stop_session(&window_label, &session_id, execution_id.as_deref()) { + tauri::async_runtime::spawn_blocking(move || terminate_run_process(pid)) + .await + .map_err(|error| format!("Run stop failed: {error}"))?; + } Ok(()) } @@ -2124,21 +2218,41 @@ fn take_owned_session( current.remove(key) } -fn stop_session(window_label: &str, session_id: &str, execution_id: Option<&str>) { - let pid = sessions().lock().ok().and_then(|mut current| { +fn stop_session(window_label: &str, session_id: &str, execution_id: Option<&str>) -> Option { + // Use the same lock/order as publication, so a stop cannot miss a process + // between its pending reservation and its running session. + let Ok(mut pending) = pending_launches().lock() else { + eprintln!("Run launch state is unavailable while stopping a session"); + return None; + }; + cancel_pending_launch( + &mut pending, + &run_session_key(window_label, session_id), + execution_id, + ); + take_running_pid(window_label, session_id, execution_id) +} + +fn take_running_pid( + window_label: &str, + session_id: &str, + execution_id: Option<&str>, +) -> Option { + sessions().lock().ok().and_then(|mut current| { take_owned_session( &mut current, &run_session_key(window_label, session_id), execution_id, ) .map(|session| session.pid) - }); - if let Some(pid) = pid { - let mut command = Command::new("taskkill"); - command.args(["/F", "/T", "/PID", &pid.to_string()]); - apply_creation_flags(&mut command); - let _ = command.output(); - } + }) +} + +fn terminate_run_process(pid: u32) { + let mut command = Command::new("taskkill"); + command.args(["/F", "/T", "/PID", &pid.to_string()]); + apply_creation_flags(&mut command); + let _ = command.output(); } #[cfg(test)] @@ -2146,6 +2260,78 @@ mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; + fn reserve_test_launch( + pending: &mut HashMap, + window: &str, + execution: &str, + ) -> LaunchReservation { + let key = run_session_key(window, "launch-reservation-test"); + let identity = Arc::new(()); + pending.insert( + key.clone(), + PendingLaunch { + identity: identity.clone(), + execution_id: Some(execution.into()), + }, + ); + LaunchReservation { key, identity } + } + + #[test] + fn pending_launch_owner_cleanup_preserves_replacement() { + // This key is exclusive to this test; every reservation has RAII cleanup + // even when an assertion unwinds. No threads or wall-clock waits needed. + let reserve = || { + let mut pending = pending_launches().lock().unwrap(); + reserve_test_launch(&mut pending, "owner-cleanup-window", "same-id") + }; + let old = reserve(); + let latest = reserve(); + let key = latest.key.clone(); + drop(old); + assert!(latest.is_current(&pending_launches().lock().unwrap())); + drop(latest); + assert!(!pending_launches().lock().unwrap().contains_key(&key)); + } + + #[test] + fn pending_launch_stop_prevents_late_publication() { + let mut pending = HashMap::new(); + let launch = reserve_test_launch(&mut pending, "first-window", "first"); + assert!(launch.is_current(&pending)); + // Preparation is still in progress when Stop arrives. Completing that + // preparation later must never grant permission to spawn a process. + cancel_pending_launch(&mut pending, &launch.key, None); + assert!(!launch.is_current(&pending)); + assert!(pending.is_empty()); + } + + #[test] + fn pending_launch_restart_rejects_out_of_order_completion() { + let mut pending = HashMap::new(); + let old = reserve_test_launch(&mut pending, "first-window", "old"); + let latest = reserve_test_launch(&mut pending, "first-window", "new"); + assert!(latest.is_current(&pending)); + assert!(!old.is_current(&pending)); + // An old adapter's delayed Stop must not cancel the replacement Run. + cancel_pending_launch(&mut pending, &old.key, Some("old")); + assert!(latest.is_current(&pending)); + cancel_pending_launch(&mut pending, &latest.key, Some("new")); + assert!(!latest.is_current(&pending)); + } + + #[test] + fn pending_launch_isolates_windows_and_reused_execution_ids() { + let mut pending = HashMap::new(); + let old = reserve_test_launch(&mut pending, "first-window", "same-id"); + let latest = reserve_test_launch(&mut pending, "first-window", "same-id"); + let other = reserve_test_launch(&mut pending, "second-window", "same-id"); + assert!(!old.is_current(&pending)); + assert!(latest.is_current(&pending)); + cancel_pending_launch(&mut pending, &latest.key, None); + assert!(other.is_current(&pending)); + } + #[test] fn output_batch_coalesces_queued_chunks_in_order() { let (sender, receiver) = mpsc::sync_channel(2);