From 935b887db140453c8b89a97f206a757861b6b992 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Fri, 13 Nov 2026 08:44:20 -0800 Subject: [PATCH 1/2] fix(java): align Viva contract and guard run lifecycle --- Cargo.lock | 4 +- README.md | 102 +++++++++++- backend/Cargo.toml | 7 +- backend/src/lib.rs | 16 +- backend/src/run.rs | 152 ++++-------------- backend/src/settings_session.rs | 26 +++ backend/tests/repository_pins.rs | 24 ++- backend/tests/session.rs | 8 +- io.github.lithe-idea.java-support/plugin.json | 25 ++- scripts/verify-sdk-lock.sh | 60 ++----- sdk.lock.json | 11 +- 11 files changed, 206 insertions(+), 229 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f540e8..458e7f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,8 +339,8 @@ dependencies = [ [[package]] name = "lithe-plugin-sdk" -version = "0.3.0" -source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs?tag=sdk-rust-v0.3.0#db17c719bad755b420600c897615c67ed8cab67c" +version = "0.4.0" +source = "git+https://github.com/Lithe-IDEA/Lithe-core-rust?rev=65cf82343c5e315aae2e335b2e8f0217b703e89a#65cf82343c5e315aae2e335b2e8f0217b703e89a" dependencies = [ "base64", "serde_json", diff --git a/README.md b/README.md index 554bdc8..aeda4fc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # Java Support for Lithe +> **Viva 最小 Maven 工作流(本地验证,尚未发布)**:清单已迁到当前契约 0,后端使用 Core +> `65cf82343c5e315aae2e335b2e8f0217b703e89a` 中的 Rust SDK 0.4.0(尚无对应发布标签)。 +> 本轮验证含第三方 JAR 依赖的普通 Maven 工程:JDT 补全、Maven 编译、main 运行和断点调试,包含变量读取、继续和退出。 +> 需要同时包含 Lithe 客户端的动作注册解码、动作刷新与首次调试加载修复;未修改这些行为的旧 preview 不能直接使用本包。 +> 插件只在 Java 文件/调试被使用时激活,不再在打开任何工作区时启动。 +> 运行动作等到 JDT 就绪才注册,复用设置会话已有的代际保护;编译报错时拒绝启动旧 class。 +> 当前宿主未提供 `workspace/findFiles`,因此暂不提供控制台堆栈链接,不申请无效的搜索权限。 +> 下文旧客户端/API 版本与发布说明是历史背景,不代表当前 Viva 已验收。 + + [Lithe](https://github.com/1lck/Lithe-IDEA) 的 Java 插件(`io.github.lithe-idea.java-support`): 打包 Eclipse JDT Language Server、Lombok、java-debug、vscode-java-test 运行器和每个架构一份 Temurin JDK, 为 Java 项目提供补全、诊断、导航、运行/调试入口、测试运行和 Spring Boot / MyBatis 索引。 @@ -22,12 +32,86 @@ > Maven Archetype 生成器、Maven 与 Gradle 的项目模板已经移交 lithe-plugin-maven 和 lithe-plugin-gradle; > 在这两个插件接手模板之前不要发布本插件的新版本,否则“新建项目”会少掉 Maven 和 Gradle 选项。 +## 当前 Viva 验证入口 + +先用 `scripts/build-package.sh --arch arm64` 与 `scripts/archive-package.sh` 构建真实插件包。 +在包含配套修复的 Lithe 检出中,把 `LITHE_TEST_JAVA_PLUGIN_ARCHIVE` 指向该 ZIP, +`LITHE_TEST_MAVEN_PLUGIN_ARCHIVE` 指向兼容当前契约的 Maven ZIP,再运行: + +```sh +scripts/desktop/desktop-go.sh test ./internal/workbench \ + -run '^TestJavaPluginMavenWorkflow$' -v -count=1 -timeout=420s +``` + +该显式集成测试使用独立用户存储和真实安装器,不修改用户已安装的插件;每轮创建新的 Maven 工程, +执行真实 Maven、JDT LS、Java 和 java-debug,断言补全候选、编译产物、第三方 JAR 类路径、输出、断点与局部变量。 +退出通知处理完毕后关闭工作区,核对两个已安装插件的全部文件 SHA-256 不变。 +运行日志由宿主的统一目录事件接收;测试不以模拟语言服务器或调试器代替它们。 +还覆盖再次调试后的主动停止、编译错误阻止旧 class 启动,并直接读取真实 Core 日志事实, +确认三个运行成功各有 INFO 终态、编译失败只有一个 `run.build_failed` ERROR、操作身份保留, +高频动作收集没有成功 INFO。Debug Stop 同时回收 Run 持有的 JVM。 + +本轮未验收原生日志窗口、JDT/适配器启动失败或启动中取消,以及所有降级/恢复分支; +上述集成结果不能替代这些日志验收。生成器、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)。 + +### 旧项目兼容性边界 + +Java 8 运行时与 Java 5/7 源码级别是两回事。实测 JDK 8 的 Maven 编译可以生成 major 49/51 的 +class,补全也可用;但包内 ECJ 3.46.100 直接拒绝 `-source 1.5` 与 `-source 1.7`,只接受 +Java 8 及以上。因此当前运行前的 JDT 构建会返回 `run.build_failed`,Java 5/7 原配置的 Run/Debug +**未跑通**,不能用已有 class 绕过构建失败,也不能把安全拒绝测试通过写成该项目受支持。 + +客户端的 `TestJavaPluginLegacyWorkflows` 验证 Java 8+ 的旧布局、跨模块依赖、中文空格路径、 +Profile 源码目录、Java 8 JAXB,以及旧 JUnit 的 Maven test goal; +`TestJavaPluginLegacySourceBoundaries` 单独断言 Java 5/7 的当前限制。Eclipse/Ant 布局通过 +`.project`、`.classpath` 导入并由 JDT 构建,不代表执行了 `build.xml` 的 Ant target。 +JUnit 的 Maven goal 也不代表 Java 插件的每一种行内测试运行器都已验收。 + +历史项目入口 `TestJavaPluginHistoricalProject` 只修改隔离副本,增加调用真实项目 API 的验证 main。 +`LITHE_TEST_JAVA_HISTORICAL_ROOT` 指向固定 commit,`LITHE_TEST_JAVA_HISTORICAL_PROBE` 提供验证表达式。 +原配置与显式 `LITHE_TEST_JAVA_HISTORICAL_SOURCE=1.8` 的临时 POM 修改必须分开报告;后者不能证明 +原 Java 5 配置可用。Java 8/11 运行时由 `LITHE_TEST_JAVA8_HOME`、`LITHE_TEST_JAVA11_HOME` 提供,测试 +会核对 class 目标版本与实际 JVM,不从 JDT 使用的随包 JDK 推断项目运行时。 + +正常关闭时宿主仍可能通过 `destroyServer` 的预期拒绝重试等待退出,产生多条 WARN;本轮未观察到 +进程残留,已有独立重构单 [Lithe-IDEA #1860](https://github.com/1lck/Lithe-IDEA/issues/1860)。 + +2026-11-13 本地结果:最终 arm64 ZIP 连续三轮上述完整流程通过,分别 18.54、18.23、19.20 秒。 +每轮隔离安装和工作区,但 Maven 依赖缓存与系统文件缓存没有清空;这不是首次联网下载或长期压力测试。 +插件 371 项 Rust 测试、客户端全量 Go 测试/`vet`、5 项定向竞态回归通过。 +小工程导入后的 JDT 进程树 RSS 约 0.8 GiB,是单点观测,不是峰值或客户端总内存。 + +本轮清单的 API 和能力版本均为 0,插件归属仍为社区普通插件。权限用途如下;保留的测试、生成器、 +Spring/MyBatis 等既有能力不属于本轮 Maven 验收范围。 + +| 权限 | 用途 | +| --- | --- | +| `backend.launch` | 启动包内 Rust 后端 | +| `documents.read` | 读取当前 Java 文档以收集动作 | +| `documents.contentProvider` | 提供 JDT 虚拟源码 | +| `editor.actions` | 注册 Run/Debug 行内动作 | +| `languageServer.launch` | 启动包内 JDT LS | +| `languageServer.request` | JDT 构建、main、测试、类路径和调试扩展请求 | +| `project.generate` | 既有 Java/Spring Boot 生成器 | +| `debug.launch`、`debug.attach`、`debug.start` | 声明 java-debug 适配器并经 Run 工作流发起调试 | +| `run.start` | 启动用户项目或测试 JVM | +| `window.messages` | 显示测试结果摘要 | +| `workspace.metadata` | 取得工作区上下文与宿主提供的状态位置 | + +参考资料为上述 Core commit 的 `shared/plugins/contract/v1/` 与 Rust SDK 0.4.0。 +该 commit 的 SDK README/日志指南写有 `sdk-rust-v0.4.0` 发布标签,但远端当时没有这个标签或资产, +因此依赖固定到同一 Core commit,不把未发布标签当作可下载输入。 + ## 功能 | 功能 | 说明 | | --- | --- | | 语言服务 | 用打包的 JDT LS(含 Lombok)提供补全、诊断、导航;JDK 随包提供,只用来运行语言服务器,项目自己的 SDK 仍由用户选择 | -| 运行与调试 | 在 `main` 方法和测试旁显示 Run/Debug 行内动作;点击后后端经 JDT LS 构建并解析类路径,用用户选择的 JDK 通过宿主 Run 窗口启动,调试经 java-debug 连接;控制台里的堆栈行可点击跳转 | +| 运行与调试 | 在 `main` 方法和测试旁显示 Run/Debug 行内动作;点击后后端经 JDT LS 构建并解析类路径,用用户选择的 JDK 通过宿主 Run 窗口启动,调试经 java-debug 连接;当前 Viva 包暂不提供控制台堆栈链接 | | 测试 | 发现 JUnit/TestNG 测试项,按类或方法启动 Eclipse JUnit 运行器(TestNG 用 vscode-java-test 运行器),运行结束后显示通过/失败计数 | | Spring Boot / MyBatis | 索引 Spring 端点与 MyBatis 映射,支持跳转 | | 新建项目 | 两个声明式项目生成器:Java(无构建系统,可选示例代码)、Spring Boot;Maven 原型和 Maven/Gradle 项目模板由 Maven、Gradle 插件提供 | @@ -63,23 +147,31 @@ ## 日志 -后端把日志交给宿主的统一日志 `litheLogging` 1,不写 stderr。函数 ID 都是编译期常量,注册只会因 ID 写错、重复或超出 SDK +后端把日志交给宿主的统一日志 `litheLogging` 0,不写 stderr。函数 ID 都是编译期常量,注册只会因 ID 写错、重复或超出 SDK 上限而失败,单元测试 `the_backend_builds_with_every_command_registered` 在 CI 里挡住这种构建;万一发生,进程在握手前以状态 1 退出, -由宿主记录启动失败(`backend/src/main.rs`)。Rust SDK(0.2.0 起,当前 0.3.0)要求宿主提供 `litheLogging` 1, -不提供的 Lithe 版本会拒绝启动后端;目前只有 Lithe 最新 `preview` 提供,稳定版还没有。 +由宿主记录启动失败(`backend/src/main.rs`)。当前固定的 Rust SDK 0.4.0 要求宿主提供 `litheLogging` 0, +不提供的 Lithe 版本会拒绝启动后端;本次只验证上述配套 Viva 检出,尚未发布。 | 事实 | 级别 | 来源 | 内容 | | --- | --- | --- | --- | -| 关键操作成功:`runConfig.selectJava`、`debug.javaTestLaunch`、`execution.planLaunchCommand`、`java.prepareLaunch`、`languageImport.settingsChanged`(`backend/src/lib.rs` 的 `KEY_OPERATIONS`) | INFO | SDK 自动 | 操作 ID、耗时 | +| 关键操作成功:`runConfig.selectJava`、`debug.javaTestLaunch`、`execution.planLaunchCommand`、`java.prepareLaunch`、`java.resolveLaunch`、`java.runAction`、`java.rerun`、`languageImport.settingsChanged`(`backend/src/lib.rs` 的 `KEY_OPERATIONS`) | INFO | SDK 自动 | 操作 ID、耗时 | | 其余函数成功(编辑器驱动或启动时的读取,如 `java.runMarkers`、`java.codeVision`、`spring.index`) | 不记录 | `suppress_success_diagnostics`(`backend/src/lib.rs`) | — | | 任一函数失败 | ERROR | SDK 自动 | 类别为 `pluginErrorCode`(如 `java.invalidRequest`;`languageImport.settingsChanged` 的 `relay.caller_missing`、`relay.context_missing`、`relay.invalid_params`)、耗时;不含消息与 `details` | +| 领域命令失败类别 | ERROR | `plugin_error_code` | `java.invalidRequest`、`java.workspaceNotFound`、`java.permissionDenied`、`java.notSupported`、`java.runtimeMissing`、`java.processStartFailed`、`java.processFailed`、`java.parseFailed`、`java.cancelled`、`java.timedOut`、`java.connectionClosed`、`java.unknown`;SDK 识别的取消不记录 | +| JDT 启动规划失败 | ERROR | SDK 自动 | `launch.invalid_params`、`launch.package_unavailable`、`launch.resource_missing`、`launch.configuration_failed` | +| JDT 配置清理或工作区读取降级 | WARN | SDK `degraded` | `launch.configuration_cleanup_failed`、`launch.workspace_folders_unavailable` | | 取消 | 不记录 | SDK | — | | 列出 `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` | +| 动作注册、上下文读取或测试结果摘要降级 | WARN | SDK `degraded` | `RUN_CATEGORIES`:`run.actions_registration_failed`、`run.contexts_unavailable`、`run.result_summary_failed` | +| 宿主启动 JDT LS、java-debug、项目 JVM | 宿主决定 | 声明式贡献/Run 生命周期 | 插件不重复发送启动终态;上游 stderr 由宿主按 DEBUG 接收 | | JDT LS 自身日志 | 上游,默认 WARNING | `plugin.json` 的 `-Dosgi.logfile=${logDir}/jdt.log` | 只写宿主提供的会话日志目录,单文件 1 MB、保留 2 个备份;用户可在设置里调 `logLevel` | 日志事实只带类别、计数和耗时,不含路径、源码、工具输出或用户内容。`${logDir}` 由宿主经可写存储适配器提供,不写插件包或 app bundle。 +Rust 后端的通道是 `plugins/io.github.lithe-idea.java-support/host`;JDT LS、java-debug 与项目 JVM +分别由宿主的语言服务器、调试和 Run 通道管理。只有 JDT LS 使用原生日志文件。 ## 安装 diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 77e49ae..5ca6480 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -12,8 +12,7 @@ path = "src/main.rs" [dependencies] lithe-java-domain = { path = "../crates/java-domain" } -# Pinned to the published Rust SDK release; ../sdk.lock.json records the -# matching commit, crate archive and SHA-256; scripts/verify-sdk-lock.sh -# checks that Cargo.lock resolved this exact commit. -lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs", tag = "sdk-rust-v0.3.0" } +# SDK source is pinned to the Viva baseline's exact Core commit. SDK 0.4.0 +# 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 diff --git a/backend/src/lib.rs b/backend/src/lib.rs index c5e2390..dee849b 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -34,7 +34,7 @@ //! //! # Logging //! -//! The backend reports restricted facts through the SDK's `litheLogging` 1 +//! The backend reports restricted facts through the SDK's `litheLogging` 0 //! channel; the SDK refuses `initialize` when the host does not offer it. The //! SDK reports each function's terminal fact by itself: a failure as //! `operationFailed` with the `pluginErrorCode`, a success as @@ -55,7 +55,7 @@ pub mod settings_session; use crate::launch::LAUNCH_FUNCTION_ID; use crate::run::{ RunSession, COLLECT_FUNCTION_ID, LANGUAGE_SERVER_NOTIFICATION, RERUN_FUNCTION_ID, - RESOLVE_LINK_FUNCTION_ID, RUN_ACTION_FUNCTION_ID, RUN_DID_EXIT_NOTIFICATION, + RUN_ACTION_FUNCTION_ID, RUN_DID_EXIT_NOTIFICATION, }; use crate::settings_session::{ HostServices, SettingsSession, LANGUAGE_SERVER_STATE_NOTIFICATION, SETTINGS_CHANGED_FUNCTION_ID, @@ -85,9 +85,8 @@ const NOTIFICATION_OPERATION: &str = "notification"; /// `java.resolveLaunch` is one too: it runs once per language server start /// and its success is the only trace of which package files the server /// started from. `java.runAction` and `java.rerun` are key operations -/// because each is one user click that starts a process; collection and -/// output link resolution run on every edit and every output line, so their -/// successes are suppressed. +/// because each is one user click that starts a process; action collection +/// runs on edits, so its successes are suppressed. pub const KEY_OPERATIONS: &[&str] = &[ "debug.javaTestLaunch", "execution.planLaunchCommand", @@ -131,8 +130,10 @@ pub fn backend() -> Result { let session = Arc::new(SettingsSession::new(Arc::new(HostServices::new( backend.handle(), )))); + let run_session = Arc::new(RunSession::new(backend.handle(), Arc::clone(&session))); let handle = backend.handle(); backend.on_host_notification(LANGUAGE_SERVER_STATE_NOTIFICATION, { + let run_session = Arc::clone(&run_session); let session = Arc::clone(&session); move |params| { // An error from `degraded` only means the arguments were invalid, @@ -143,6 +144,7 @@ pub fn backend() -> Result { .degraded(NOTIFICATION_OPERATION, code, Some(count)); }; session.did_change_state(¶ms, &report); + run_session.sync_contexts_reporting(&report); Ok(()) } })?; @@ -157,7 +159,6 @@ pub fn backend() -> Result { // The run session registers the editor actions of every workspace context // and keeps the result listener of each test run; both happen outside a // function call, so it holds the backend handle too. - let run_session = Arc::new(RunSession::new(backend.handle())); let report: run::Report = { let handle = backend.handle(); Arc::new(move |code: &str, count: u64| { @@ -207,8 +208,6 @@ pub fn backend() -> Result { let run_session = Arc::clone(&run_session); move |params, context| run::rerun_function(&run_session, params, context) })?; - backend.function(RESOLVE_LINK_FUNCTION_ID, run::resolve_output_link_function)?; - backend.suppress_success_diagnostics(RESOLVE_LINK_FUNCTION_ID)?; Ok(backend) } @@ -410,7 +409,6 @@ mod tests { COLLECT_FUNCTION_ID, RUN_ACTION_FUNCTION_ID, RERUN_FUNCTION_ID, - RESOLVE_LINK_FUNCTION_ID, ]) .collect(); let distinct: std::collections::BTreeSet<&str> = KEY_OPERATIONS.iter().copied().collect(); diff --git a/backend/src/run.rs b/backend/src/run.rs index e224cef..0b64e16 100644 --- a/backend/src/run.rs +++ b/backend/src/run.rs @@ -25,9 +25,6 @@ //! 4. A test launch opens one loopback port the Eclipse JUnit runner reports //! to. A listener thread counts the results and shows them in a window //! message; it stops when the host reports `run/didExit`. -//! 5. The output link matcher of the manifest asks -//! [`RESOLVE_LINK_FUNCTION_ID`] to turn a stack frame into a file of the -//! workspace. //! //! # Not yet covered //! @@ -38,7 +35,7 @@ //! host's i18n service (API-37). use crate::launch::{self, TEST_RUNNER}; -use crate::settings_session::{EXECUTE_COMMAND, REQUEST_METHOD, SERVER_ID}; +use crate::settings_session::{SettingsSession, EXECUTE_COMMAND, REQUEST_METHOD, SERVER_ID}; use lithe_java_domain::debug::java_test_launch; use lithe_java_domain::lsp::languages::java_main_methods::{ java_main_methods_command, normalize_java_main_methods, @@ -75,10 +72,6 @@ pub const RUN_ACTION_FUNCTION_ID: &str = "java.runAction"; /// the start passed as `rerun.data`, which are the action's params. pub const RERUN_FUNCTION_ID: &str = "java.rerun"; -/// Function the manifest's output link matcher names: it maps a stack frame -/// (`at pkg.Class.method(Class.java:12)`) to a file of the workspace. -pub const RESOLVE_LINK_FUNCTION_ID: &str = "java.resolveOutputLink"; - /// Registration ID of the editor actions binding, the same in each context. pub const ACTIONS_BINDING_ID: &str = "javaRunActions"; @@ -117,10 +110,6 @@ const RESOLVE_CLASSPATH_COMMAND: &str = "vscode.java.resolveClasspath"; /// `vscode.java.buildWorkspace` answer that means the build succeeded. const BUILD_SUCCEEDED: i64 = 1; -/// `vscode.java.buildWorkspace` answer that means the build finished with -/// compile errors; JDT LS still produces class files, so the launch goes on. -const BUILD_WITH_ERRORS: i64 = 2; - /// Timeout of the JDT LS requests a launch makes; a workspace build may take /// a while on a first run. const LAUNCH_REQUEST_TIMEOUT_MILLISECONDS: u64 = 120_000; @@ -132,9 +121,6 @@ const COLLECT_REQUEST_TIMEOUT_MILLISECONDS: u64 = 6_000; /// Most actions one answer carries; the contract refuses more. const MAX_ACTIONS: usize = 1000; -/// Most files `findFiles` returns for one stack frame. -const LINK_SEARCH_LIMIT: u64 = 50; - /// How long the result listener waits for the test runner to connect before /// it gives up on its own; the normal end is the `run/didExit` notification. const RESULT_CONNECTION_TIMEOUT: Duration = Duration::from_secs(600); @@ -210,6 +196,7 @@ pub const RUN_CATEGORIES: &[&str] = &[ /// result listener per running test launch. Shared between the functions, /// the notification handlers and the listener threads. pub struct RunSession { + settings: Arc, handle: BackendHandle, registrations: Mutex>, listeners: Mutex>>, @@ -285,9 +272,10 @@ impl Target { } impl RunSession { - pub fn new(handle: BackendHandle) -> Self { + pub fn new(handle: BackendHandle, settings: Arc) -> Self { Self { handle, + settings, registrations: Mutex::new(HashMap::new()), listeners: Mutex::new(HashMap::new()), } @@ -313,6 +301,9 @@ impl RunSession { let Ok(reference) = WorkspaceContext::from_value(context) else { continue; }; + if !self.settings.server_is_running(&reference) { + continue; + } seen.insert(reference.context_id().to_owned()); if let Some(existing) = registrations.get(reference.context_id()) { if existing.generation == reference.generation() { @@ -635,6 +626,26 @@ fn start(session: &RunSession, target: &Target, context: &Context) -> Result Result<(), FunctionError> { + match build.as_i64() { + Some(BUILD_SUCCEEDED) => {} + Some(_) => { + return Err(failure( + RUN_BUILD_FAILED, + "The workspace build failed or was cancelled.", + )) + } + None => { + return Err(failure( + RUN_LANGUAGE_SERVER_FAILED, + "The build answered without a result code.", + )) + } + } + Ok(()) +} + /// The `java` arguments and working directory of one launch. struct Process { arguments: Vec, @@ -669,21 +680,7 @@ fn main_process( LAUNCH_REQUEST_TIMEOUT_MILLISECONDS, ) .map_err(language_server_failed)?; - match build.as_i64() { - Some(BUILD_SUCCEEDED | BUILD_WITH_ERRORS) => {} - Some(_) => { - return Err(failure( - RUN_BUILD_FAILED, - "The workspace build failed or was cancelled.", - )) - } - None => { - return Err(failure( - RUN_LANGUAGE_SERVER_FAILED, - "The build answered without a result code.", - )) - } - } + require_successful_build(&build)?; let classpath = execute_command( host, reference, @@ -855,92 +852,6 @@ fn relative_working_directory(root: &Path, working_directory: &str) -> String { } } -/// Resolves a stack frame of the run output to a file of the workspace: -/// `{groups: {qualified, file, line}}` to `{uri, range}` or `null`. -pub fn resolve_output_link_function( - params: Value, - context: &Context, -) -> Result { - context.check_cancelled()?; - let reference = workspace_context(context)?; - let group = |name: &str| { - params - .pointer(&format!("/groups/{name}")) - .and_then(Value::as_str) - }; - let Some(file) = group("file").filter(|file| !file.is_empty()) else { - return Ok(Value::Null); - }; - let line = group("line") - .and_then(|line| line.parse::().ok()) - .filter(|line| *line >= 1) - .map_or(0, |line| line - 1); - let package_directory = group("qualified") - .and_then(|qualified| qualified.rsplit_once('.')) - .map(|(package, _)| package.replace('.', "/")) - .unwrap_or_default(); - let host = context.host(); - let workspace = host.workspace().get_context(reference)?; - let Some(folders) = workspace.get("folders").and_then(Value::as_array) else { - return Ok(Value::Null); - }; - let mut best: Option<(PathBuf, usize)> = None; - for folder in folders { - let (Some(root_id), Some(root)) = ( - folder.get("rootID").and_then(Value::as_str), - folder.get("path").and_then(Value::as_str), - ) else { - continue; - }; - let found = host.workspace().find_files( - reference, - json!({ - "resource": {"rootID": root_id, "path": ""}, - "query": file, - "limit": LINK_SEARCH_LIMIT, - }), - )?; - for found in found - .get("matches") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let Some(path) = found.get("path").and_then(Value::as_str) else { - continue; - }; - if Path::new(path) - .file_name() - .map(|name| name.to_string_lossy()) - != Some(file.into()) - { - continue; - } - let expected = format!("{package_directory}/{file}"); - // A file in the right package directory beats any other file of - // the same name; among several, the first keeps the place. - let score = if package_directory.is_empty() || path.ends_with(&expected) { - 2 - } else { - 1 - }; - if best.as_ref().is_none_or(|(_, current)| score > *current) { - best = Some((Path::new(root).join(path), score)); - } - } - } - Ok(match best { - Some((path, _)) => json!({ - "uri": file_uri(&path), - "range": { - "start": {"line": line, "character": 0}, - "end": {"line": line, "character": 0}, - }, - }), - None => Value::Null, - }) -} - /// Counts the results the Eclipse JUnit runner streams to the result port /// and shows them when the runner disconnects. struct ResultListener { @@ -1227,6 +1138,13 @@ mod tests { use super::*; use lithe_java_domain::lsp::languages::JavaRunMarkerStatus; + #[test] + fn compiler_errors_do_not_launch_previous_bytecode() { + assert!(require_successful_build(&json!(BUILD_SUCCEEDED)).is_ok()); + assert!(require_successful_build(&json!(2)).is_err()); + assert!(require_successful_build(&Value::Null).is_err()); + } + #[test] fn only_a_service_ready_status_of_the_java_server_names_its_context() { let ready = json!({ diff --git a/backend/src/settings_session.rs b/backend/src/settings_session.rs index f4e6ba6..3113459 100644 --- a/backend/src/settings_session.rs +++ b/backend/src/settings_session.rs @@ -533,6 +533,15 @@ impl SettingsSession { } } + /// Action collection must not call JDT before its authoritative running event. + pub(crate) fn server_is_running(&self, context: &WorkspaceContext) -> bool { + self.lock_states() + .get(context.context_id()) + .is_some_and(|state| { + state.context.generation() == context.generation() && state.is_running() + }) + } + fn lock_states(&self) -> MutexGuard<'_, HashMap> { lock(&self.states) } @@ -985,6 +994,23 @@ mod tests { json!({ "settings": { "java.import.maven.enabled": value } }) } + #[test] + fn actions_wait_for_running_and_ignore_late_events_after_stop() { + let (_script, session) = fixture(); + assert!(!session.server_is_running(&workspace())); + session.did_change_state(&java("starting", 1), &quiet); + assert!(!session.server_is_running(&workspace())); + session.did_change_state(&java("running", 1), &quiet); + assert!(session.server_is_running(&workspace())); + session.did_change_state(&java("stopped", 1), &quiet); + session.did_change_state(&java("running", 1), &quiet); + assert!(!session.server_is_running(&workspace())); + session.did_change_state(&java("running", 2), &quiet); + assert!(session.server_is_running(&workspace())); + let replaced = WorkspaceContext::new("ctx-1", 2).unwrap(); + assert!(!session.server_is_running(&replaced)); + } + #[test] fn the_constants_match_the_contract() { assert_eq!( diff --git a/backend/tests/repository_pins.rs b/backend/tests/repository_pins.rs index 008fae6..482bf9c 100644 --- a/backend/tests/repository_pins.rs +++ b/backend/tests/repository_pins.rs @@ -39,21 +39,20 @@ fn text<'a>(value: &'a Value, key: &str) -> &'a str { fn cargo_resolves_the_sdk_commit_recorded_in_the_lock() { let lock = read_json("sdk.lock.json"); let repository = text(&lock, "repository"); - let tag = text(&lock, "tag"); + let revision = text(&lock, "commit"); let commit = text(&lock, "commit"); assert_eq!(commit.len(), 40, "the lock must name a full commit"); - assert!(text(&lock, "url").starts_with(&format!("{repository}/releases/download/{tag}/"))); - assert_sha256(text(&lock, "sha256"), "sdk.lock.json sha256"); let manifest = read("backend/Cargo.toml"); - let dependency = format!(r#"lithe-plugin-sdk = {{ git = "{repository}", tag = "{tag}" }}"#); + let dependency = + format!(r#"lithe-plugin-sdk = {{ git = "{repository}", rev = "{revision}" }}"#); assert!( manifest.lines().any(|line| line.trim() == dependency), "backend/Cargo.toml must depend on `{dependency}`" ); let cargo_lock = read("Cargo.lock"); - let source = format!(r#"source = "git+{repository}?tag={tag}#{commit}""#); + let source = format!(r#"source = "git+{repository}?rev={revision}#{commit}""#); let package = cargo_lock .split("[[package]]") .find(|package| package.contains("name = \"lithe-plugin-sdk\"")) @@ -69,7 +68,7 @@ fn cargo_resolves_the_sdk_commit_recorded_in_the_lock() { fn the_manifest_keeps_the_community_identity_and_launches_the_packaged_backend() { let manifest = read_json(&format!("{PLUGIN_ID}/plugin.json")); assert_eq!(text(&manifest, "id"), PLUGIN_ID); - assert_eq!(manifest["schemaVersion"], 2); + assert_eq!(manifest["schemaVersion"], 0); // Lithe Store lists only unsigned community packages: the vendor is the // organization namespace and the package carries no publisher signature, // so nothing may require one or ask the host to check it. @@ -161,15 +160,10 @@ fn language_server_launch_is_resolved_by_the_backend() { ); } let requires = &manifest["requires"]; - assert!( - requires["serverProcess"].as_u64().unwrap() >= 3, - "backend launch needs serverProcess 3" - ); - assert!( - requires["backend"].as_u64().unwrap() >= 2, - "resolveParams need backend 2" - ); - assert!(manifest["pluginAPIVersion"].as_u64().unwrap() >= 4); + assert!(requires.get("serverProcess").is_none()); + assert!(requires.get("executableResolution").is_none()); + assert_eq!(requires["backend"], 0); + assert_eq!(manifest["pluginAPIVersion"], 0); } #[test] diff --git a/backend/tests/session.rs b/backend/tests/session.rs index 1eb48ed..20a98f4 100644 --- a/backend/tests/session.rs +++ b/backend/tests/session.rs @@ -96,8 +96,8 @@ impl Host { "initialize", json!({"rootUri": "file:///tmp/state/", "capabilities": { "experimental": { - "litheFunctions": {"versions": [1]}, - "litheLogging": {"versions": [1]} + "litheFunctions": {"versions": [0]}, + "litheLogging": {"versions": [0]} } }}), ); @@ -138,7 +138,7 @@ fn the_backend_offers_the_java_commands_as_plugin_functions() { assert!(encoded.contains("java.className"), "{encoded}"); assert!(encoded.contains("runConfig.selectJava"), "{encoded}"); assert_eq!( - initialized["result"]["capabilities"]["experimental"]["litheLogging"]["version"], 1, + initialized["result"]["capabilities"]["experimental"]["litheLogging"]["version"], 0, "{initialized}" ); @@ -253,7 +253,7 @@ fn initialize_without_lithe_logging_is_refused() { 0, "initialize", json!({"rootUri": "file:///tmp/state/", "capabilities": { - "experimental": {"litheFunctions": {"versions": [1]}} + "experimental": {"litheFunctions": {"versions": [0]}} }}), ); let refused = host.answer(0); diff --git a/io.github.lithe-idea.java-support/plugin.json b/io.github.lithe-idea.java-support/plugin.json index 6f6e2a7..b40cf79 100644 --- a/io.github.lithe-idea.java-support/plugin.json +++ b/io.github.lithe-idea.java-support/plugin.json @@ -1,18 +1,18 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "io.github.lithe-idea.java-support", "displayName": "Java Support", "version": "1.1.0", - "pluginAPIVersion": 4, - "requires": {"backend": 2, "debugAdapters": 3, "languages": 1, "languageServers": 3, "outputLinkMatchers": 1, "pluginFunctions": 1, "serverProcess": 4, "settings": 2, "projectGenerators": 1, "toolDefinitions": 1, "executableResolution": 2}, + "pluginAPIVersion": 0, + "requires": {"backend": 0, "debugAdapters": 0, "languages": 0, "languageServers": 0, "pluginFunctions": 0, "settings": 0, "projectGenerators": 0, "toolDefinitions": 0}, "requiresTools": ["java"], "vendor": { "id": "io.github.lithe-idea", "displayName": "Lithe Community", "signatureRequirement": "none" }, - "permissions": ["backend.launch", "documents.contentProvider", "documents.read", "editor.actions", "languageServer.launch", "languageServer.request", "project.generate", "debug.launch", "debug.attach", "debug.start", "run.start", "window.messages", "workspace.metadata", "workspace.search"], - "activationEvents": ["onLanguage:java", "onDebug:java", "onWorkspaceOpen"], + "permissions": ["backend.launch", "documents.contentProvider", "documents.read", "editor.actions", "languageServer.launch", "languageServer.request", "project.generate", "debug.launch", "debug.attach", "debug.start", "run.start", "window.messages", "workspace.metadata"], + "activationEvents": ["onLanguage:java", "onDebug:java"], "toolDefinitions": [ { "id": "java", @@ -190,13 +190,6 @@ } } ], - "outputLinkMatchers": [ - { - "id": "stack-frame", - "pattern": "at (?P[\\w$.]+)\\.[\\w$<>]+\\((?P\\w+\\.java):(?P\\d+)\\)", - "resolve": {"function": "java.resolveOutputLink"} - } - ], "settings": [ { "id": "heapMaxMB", @@ -273,9 +266,9 @@ "fields": [ { "id": "jdk", - "type": "runtime", + "type": "tool", "title": "JDK", - "runtime": "java", + "tool": "java", "minimumVersion": "11" }, { @@ -373,10 +366,10 @@ }, { "id": "jdk", - "type": "runtime", + "type": "tool", "title": "JDK", "description": "The JDK the new project uses. It must be at least the Java version below.", - "runtime": "java", + "tool": "java", "minimumVersion": "17" }, { diff --git a/scripts/verify-sdk-lock.sh b/scripts/verify-sdk-lock.sh index b9f25d4..d254625 100755 --- a/scripts/verify-sdk-lock.sh +++ b/scripts/verify-sdk-lock.sh @@ -1,55 +1,15 @@ #!/usr/bin/env bash - -# Checks that the backend links exactly the plugin SDK recorded in -# sdk.lock.json. -# -# Without arguments the check is offline: Cargo.toml must name the locked tag -# and Cargo.lock must resolve the locked commit (backend/tests/repository_pins.rs). -# With --download it also fetches the published crate archive into -# .artifacts/sdk/ and refuses it unless its SHA-256 matches the lock, which -# proves the tag still points at the release the lock was written for. - +# Cargo verifies the exact SDK git revision; no matching release archive exists yet. set -euo pipefail - ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -LOCK="$ROOT_DIR/sdk.lock.json" -DOWNLOAD=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --download) DOWNLOAD=true; shift ;; - *) echo "Usage: $0 [--download]" >&2; exit 2 ;; - esac -done - +if [[ $# -gt 1 ]]; then + echo "Usage: $0 [--download]" >&2 + exit 2 +fi +case "${1:-}" in + "") ;; + --download) cargo fetch --locked --manifest-path "$ROOT_DIR/Cargo.toml" ;; + *) echo "Usage: $0 [--download]" >&2; exit 2 ;; +esac cargo test --locked --manifest-path "$ROOT_DIR/Cargo.toml" -p lithe-java-backend \ --test repository_pins cargo_resolves_the_sdk_commit_recorded_in_the_lock - -[[ "$DOWNLOAD" == true ]] || exit 0 - -sha256() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$1" | cut -d' ' -f1 - else - shasum -a 256 "$1" | cut -d' ' -f1 - fi -} - -url="$(jq -r .url "$LOCK")" -expected="$(jq -r .sha256 "$LOCK")" -version="$(jq -r .version "$LOCK")" -destination="$ROOT_DIR/.artifacts/sdk/lithe-plugin-sdk-rust-$version-$expected.crate" -mkdir -p "$(dirname "$destination")" -if [[ ! -f "$destination" || "$(sha256 "$destination")" != "$expected" ]]; then - temporary="$destination.download.$$" - trap 'rm -f "$temporary"' EXIT - curl --fail --location --silent --show-error --retry 3 \ - --connect-timeout 20 --max-time 300 --output "$temporary" "$url" - actual="$(sha256 "$temporary")" - if [[ "$actual" != "$expected" ]]; then - echo "SDK archive checksum mismatch: expected $expected, got $actual ($url)" >&2 - exit 1 - fi - mv "$temporary" "$destination" -fi -echo "Verified SDK archive $url" diff --git a/sdk.lock.json b/sdk.lock.json index 76e1270..79b905a 100644 --- a/sdk.lock.json +++ b/sdk.lock.json @@ -1,9 +1,6 @@ { - "comment": "Lithe Rust plugin SDK used by backend/. backend/Cargo.toml depends on the git tag; the backend tests require Cargo.lock to resolve exactly this commit, and scripts/verify-sdk-lock.sh --download also checks the published crate archive against sha256.", - "version": "0.3.0", - "repository": "https://github.com/Lithe-IDEA/lithe-plugin-docs", - "tag": "sdk-rust-v0.3.0", - "commit": "db17c719bad755b420600c897615c67ed8cab67c", - "url": "https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/download/sdk-rust-v0.3.0/lithe-plugin-sdk-rust-0.3.0.crate", - "sha256": "5828293d73558fb14dc792f102c1b3cc9133fd220659b26cf17856506dc71bb8" + "comment": "SDK 0.4.0 from the exact Core revision used by the Viva integration baseline. No SDK release tag exists yet; Cargo.lock pins the git source.", + "version": "0.4.0", + "repository": "https://github.com/Lithe-IDEA/Lithe-core-rust", + "commit": "65cf82343c5e315aae2e335b2e8f0217b703e89a" } From ba8c5fb154edb4d76dd0dd03c6ef10b9ffd0d7bb Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Fri, 13 Nov 2026 08:59:42 -0800 Subject: [PATCH 2/2] fix(java): refresh actions after progressive project imports --- README.md | 14 +++++- backend/src/run.rs | 86 ++++++++++++++++++++++++--------- backend/src/settings_session.rs | 24 ++++++++- backend/tests/session.rs | 2 +- 4 files changed, 99 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index aeda4fc..3b5fadb 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ > 需要同时包含 Lithe 客户端的动作注册解码、动作刷新与首次调试加载修复;未修改这些行为的旧 preview 不能直接使用本包。 > 插件只在 Java 文件/调试被使用时激活,不再在打开任何工作区时启动。 > 运行动作等到 JDT 就绪才注册,复用设置会话已有的代际保护;编译报错时拒绝启动旧 class。 +> 渐进导入的后续模块还会发送 ProjectsImported / ClasspathUpdated / ProjectsDeleted,插件按事件刷新动作, +> 不把首次 ServiceReady 当成所有模块已导入;旧服务器或工作区代际的迟到通知会被丢弃。 > 当前宿主未提供 `workspace/findFiles`,因此暂不提供控制台堆栈链接,不申请无效的搜索权限。 > 下文旧客户端/API 版本与发布说明是历史背景,不代表当前 Viva 已验收。 @@ -77,12 +79,22 @@ JUnit 的 Maven goal 也不代表 Java 插件的每一种行内测试运行器 原 Java 5 配置可用。Java 8/11 运行时由 `LITHE_TEST_JAVA8_HOME`、`LITHE_TEST_JAVA11_HOME` 提供,测试 会核对 class 目标版本与实际 JVM,不从 JDT 使用的随包 JDK 推断项目运行时。 +| 历史源码基线 | 原 Java 5 POM | 仅在副本把 source/target 改为 1.8 | +| --- | --- | --- | +| Commons CLI 1.3.1,`41d3dbf00f3e2041f5e407b9b96d8f048ab388d9` | Maven 编译/补全通过,Run/Debug 被拒绝 | 补全、编译、API 验证 main、断点变量、继续/停止通过,22.13 秒 | +| Joda-Time 2.3,`b0fcbb950326cbb47452670de9e666ca119ef53d` | Maven 编译/补全通过,Run/Debug 被拒绝 | 同上,23.71 秒 | + +两者都增加了调用真实项目 API 的验证入口;未声称运行了原项目的完整应用、所有测试或时区数据生成。 +下载源码保持只读,未把第三方源码、产物或用户路径提交到客户端。 + 正常关闭时宿主仍可能通过 `destroyServer` 的预期拒绝重试等待退出,产生多条 WARN;本轮未观察到 进程残留,已有独立重构单 [Lithe-IDEA #1860](https://github.com/1lck/Lithe-IDEA/issues/1860)。 2026-11-13 本地结果:最终 arm64 ZIP 连续三轮上述完整流程通过,分别 18.54、18.23、19.20 秒。 每轮隔离安装和工作区,但 Maven 依赖缓存与系统文件缓存没有清空;这不是首次联网下载或长期压力测试。 -插件 371 项 Rust 测试、客户端全量 Go 测试/`vet`、5 项定向竞态回归通过。 +初轮插件 371 项 Rust 测试、客户端全量 Go 测试/`vet`、5 项定向竞态回归通过; +扩展测试发现渐进导入动作刷新遗漏,补上事件驱动刷新和服务器代际校验后,Rust 372 项通过, +真实含中文空格路径的跨模块工程连续五轮完整流程通过。修复前的偶发失败保留在验证记录中,不能只报告重跑成功。 小工程导入后的 JDT 进程树 RSS 约 0.8 GiB,是单点观测,不是峰值或客户端总内存。 本轮清单的 API 和能力版本均为 0,插件归属仍为社区普通插件。权限用途如下;保留的测试、生成器、 diff --git a/backend/src/run.rs b/backend/src/run.rs index 0b64e16..54650ba 100644 --- a/backend/src/run.rs +++ b/backend/src/run.rs @@ -391,18 +391,25 @@ impl RunSession { .ok(); } - /// Re-registers the actions binding of a context whose language server - /// just became ready, so the host collects the actions of the open - /// documents again. - /// - /// The host collects once when a binding is registered and then only on - /// edits. The first registration runs right after `initialize`, while JDT - /// LS is still starting, so that collection answers no actions; without - /// this the gutter stays empty until the user edits the file. + /// Refreshes open-document actions after readiness or a JDT project-model + /// 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) = service_ready_context(params) else { + let Some(context_id) = action_refresh_context(params) else { + return; + }; + let Ok(reference) = WorkspaceContext::from_value(¶ms["context"]) else { + return; + }; + let Some(generation) = params.get("generation").and_then(Value::as_u64) else { return; }; + if !self + .settings + .accepts_server_notification(&reference, generation) + { + return; + } if let Some(stale) = lock(&self.registrations).remove(&context_id) { let _ = stale.subscription.dispose(); } @@ -1111,17 +1118,27 @@ fn failure(code: &'static str, message: impl Into) -> FunctionError { PluginError::new(code, message).into() } -/// The context ID of a forwarded `language/status` `ServiceReady` of the -/// `java` server, `None` for every other notification. -fn service_ready_context(params: &Value) -> Option { - if params.get("serverID").and_then(Value::as_str) != Some(SERVER_ID) - || params.get("method").and_then(Value::as_str) != Some(LANGUAGE_STATUS_METHOD) - || params - .get("params") - .and_then(|status| status.get("type")) - .and_then(Value::as_str) - != Some(SERVICE_READY_TYPE) - { +/// 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_refresh_context(params: &Value) -> Option { + if params.get("serverID").and_then(Value::as_str) != Some(SERVER_ID) { + return None; + } + let refresh = match params.get("method").and_then(Value::as_str) { + Some(LANGUAGE_STATUS_METHOD) => { + params.pointer("/params/type").and_then(Value::as_str) == Some(SERVICE_READY_TYPE) + } + Some("language/eventNotification") => { + let event = ¶ms["params"]["eventType"]; + matches!( + event.as_str(), + Some("ClasspathUpdated" | "ProjectsImported" | "ProjectsDeleted") + ) || matches!(event.as_u64(), Some(100 | 200 | 210)) + } + _ => false, + }; + if !refresh { return None; } WorkspaceContext::from_value(¶ms["context"]) @@ -1146,7 +1163,7 @@ mod tests { } #[test] - fn only_a_service_ready_status_of_the_java_server_names_its_context() { + fn a_service_ready_status_of_the_java_server_names_its_context() { let ready = json!({ "context": {"contextID": "ctx-1", "generation": 3}, "serverID": SERVER_ID, @@ -1154,7 +1171,7 @@ mod tests { "method": LANGUAGE_STATUS_METHOD, "params": {"type": SERVICE_READY_TYPE, "message": "ServiceReady"}, }); - assert_eq!(service_ready_context(&ready).as_deref(), Some("ctx-1")); + assert_eq!(action_refresh_context(&ready).as_deref(), Some("ctx-1")); let mut other_server = ready.clone(); other_server["serverID"] = json!("kotlin"); let mut other_method = ready.clone(); @@ -1166,7 +1183,30 @@ mod tests { let mut no_context = ready.clone(); no_context.as_object_mut().unwrap().remove("context"); for value in [other_server, other_method, starting, no_params, no_context] { - assert_eq!(service_ready_context(&value), None, "{value}"); + assert_eq!(action_refresh_context(&value), None, "{value}"); + } + } + + #[test] + fn progressive_import_and_classpath_changes_refresh_actions() { + for event in [ + json!("ProjectsImported"), + json!("ClasspathUpdated"), + json!("ProjectsDeleted"), + json!(100), + json!(200), + json!(210), + ] { + let notification = json!({ + "context": {"contextID":"ctx-1", "generation":1}, + "serverID":SERVER_ID, "generation":1, + "method":"language/eventNotification", "params":{"eventType":event, "data":[]}, + }); + assert_eq!( + action_refresh_context(¬ification).as_deref(), + Some("ctx-1"), + "{event}" + ); } } diff --git a/backend/src/settings_session.rs b/backend/src/settings_session.rs index 3113459..6f394ac 100644 --- a/backend/src/settings_session.rs +++ b/backend/src/settings_session.rs @@ -535,10 +535,26 @@ impl SettingsSession { /// Action collection must not call JDT before its authoritative running event. pub(crate) fn server_is_running(&self, context: &WorkspaceContext) -> bool { + self.running_server_generation(context).is_some() + } + + /// A delayed notification from a stopped/replaced process cannot refresh + /// actions belonging to a new process or a new workspace generation. + pub(crate) fn accepts_server_notification( + &self, + context: &WorkspaceContext, + generation: u64, + ) -> bool { + self.running_server_generation(context) == Some(generation) + } + + fn running_server_generation(&self, context: &WorkspaceContext) -> Option { self.lock_states() .get(context.context_id()) - .is_some_and(|state| { - state.context.generation() == context.generation() && state.is_running() + .filter(|state| state.context.generation() == context.generation()) + .and_then(|state| match state.seen { + Some((generation, ServerPhase::Running)) => Some(generation), + _ => None, }) } @@ -1002,13 +1018,17 @@ mod tests { assert!(!session.server_is_running(&workspace())); session.did_change_state(&java("running", 1), &quiet); assert!(session.server_is_running(&workspace())); + assert!(session.accepts_server_notification(&workspace(), 1)); session.did_change_state(&java("stopped", 1), &quiet); session.did_change_state(&java("running", 1), &quiet); assert!(!session.server_is_running(&workspace())); session.did_change_state(&java("running", 2), &quiet); assert!(session.server_is_running(&workspace())); + assert!(!session.accepts_server_notification(&workspace(), 1)); + assert!(session.accepts_server_notification(&workspace(), 2)); let replaced = WorkspaceContext::new("ctx-1", 2).unwrap(); assert!(!session.server_is_running(&replaced)); + assert!(!session.accepts_server_notification(&replaced, 2)); } #[test] diff --git a/backend/tests/session.rs b/backend/tests/session.rs index 20a98f4..b7deb79 100644 --- a/backend/tests/session.rs +++ b/backend/tests/session.rs @@ -89,7 +89,7 @@ impl Host { } /// Sends `initialize` as a host that offers plugin functions and - /// `litheLogging` 1, and returns the answer. + /// `litheLogging` 0, and returns the answer. fn initialize(&mut self) -> Value { self.request( 0,