From c65bc29d20fb6a42d3a4859b8de0de44e9c5eec8 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 5 Nov 2026 15:20:35 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(logging):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E7=BB=9F=E4=B8=80=E6=97=A5=E5=BF=97=20litheL?= =?UTF-8?q?ogging=201=EF=BC=88Rust=20SDK=200.2.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SDK 固定到 lithe-plugin-sdk-rust 0.2.0(sdk.lock.json、backend/Cargo.toml、Cargo.lock); 宿主不提供 litheLogging 1 时 SDK 拒绝 initialize。 - 终态事实交给 SDK 自动上报:成功 operationFinished(INFO),失败 operationFailed(ERROR, code 为 pluginErrorCode)。只有 KEY_OPERATIONS 里的四个关键操作上报成功事实,其余只读函数 注册后用 suppress_success_diagnostics 关闭成功事实。 - 新增 java-domain host::diagnostics:领域代码通过线程局部 reporter 上报降级类别与计数, 不依赖 SDK;后端在每次调用里用 scoped 接到 Diagnostics::degraded。 - 存储清理失败、argfile 删除失败改为上报 storage.cleanup_failed. 计数,不再写路径或消息。 - 跨插件函数的收集上报 relay.* 降级类别(提供方不可用、失败、超时、设置被拒、键被拒), 只带计数,不带键名或宿主消息。 - 删除旧的 Context::log 调用;main.rs 注册失败只打印稳定错误码。 - 测试:session 集成测试验证 litheLogging 协商、受限日志字段、只读函数的成功事实被关闭, 以及缺少 litheLogging 时 initialize 被拒;领域与后端新增 diagnostics、relay 上报测试。 - 文档:README 新增“日志”一节和级别表;预发布原因加入 litheLogging 1;MIGRATION 更新测试计数, 说明 #1742 已合入、中转形状要等支持插件函数协议 4 的 SDK 再对齐。 --- .github/workflows/release.yml | 10 +- Cargo.lock | 11 +- MIGRATION.md | 87 +++--- README.md | 27 +- backend/Cargo.toml | 2 +- backend/src/lib.rs | 97 +++++-- backend/src/main.rs | 10 +- backend/src/plugin_functions.rs | 281 ++++++++++++++++---- backend/tests/session.rs | 145 +++++++++- crates/java-domain/src/execution/argfile.rs | 6 +- crates/java-domain/src/host/diagnostics.rs | 222 ++++++++++++++++ crates/java-domain/src/host/mod.rs | 5 + crates/java-domain/src/host/storage.rs | 26 +- sdk.lock.json | 10 +- 14 files changed, 783 insertions(+), 156 deletions(-) create mode 100644 crates/java-domain/src/host/diagnostics.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ccdf8d..d65bca0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ name: Release # # No step receives a signing key. Checkouts do not keep the workflow token. # -# Releases stay pre-releases, which Lithe Store's tracker skips, for two +# Releases stay pre-releases, which Lithe Store's tracker skips, for three # reasons: # 1. Lithe still bundles Java Support under the old ID # dev.lithe.plugin.java-support. @@ -13,9 +13,13 @@ name: Release # integration) to dev.lithe.* plugin IDs and accept the short tool ID # "java" only from verified official provenance, so they reject this # package. +# 3. The backend links Rust plugin SDK 0.2.0, which requires the host to +# offer litheLogging 1 and refuses to start without it. Only the latest +# Lithe preview offers it so far; no stable Lithe does. # Remove --prerelease below once a stable Lithe accepts the package (stage E in -# MIGRATION.md) and stops bundling Java Support. The Store then lists stable -# releases of this repository automatically. +# MIGRATION.md), stops bundling Java Support, and offers litheLogging 1 (Rust +# SDK 0.2.0 requires it). The Store then lists stable releases of this +# repository automatically. on: push: tags: ["v*"] diff --git a/Cargo.lock b/Cargo.lock index 39e1130..91ae657 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "block-buffer" version = "0.10.4" @@ -333,9 +339,10 @@ dependencies = [ [[package]] name = "lithe-plugin-sdk" -version = "0.1.0" -source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs?tag=sdk-rust-v0.1.0#e689971c000c15a044df0502052fe7b2c1c6f84f" +version = "0.2.0" +source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs?tag=sdk-rust-v0.2.0#940394fcd8455186ee985e6c34a319032414607f" dependencies = [ + "base64", "serde_json", ] diff --git a/MIGRATION.md b/MIGRATION.md index 6107691..173c699 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -17,10 +17,10 @@ - 来源快照:Lithe `origin/preview` 的提交 `1daaeb525`。表里的路径都写成 `路径@1daaeb525`,只读,不在本仓库改动。 - 本仓库产物: - - Rust 工作区共 22,847 行 Rust:`crates/java-domain`(领域规则,不依赖宿主)和 `backend`(基于已固定版本 SDK 的插件后端进程)。 - - 295 个测试:领域 crate 272 个,后端 23 个。全部是有界、确定性的:不联网,不读全局时间,不占固定端口。 - 其中跨插件函数相关的 24 个(`jdt_contributions.rs` 10 个、`plugin_functions.rs` 14 个)是在不允许编译的约束下写成的, - 还没有运行过,见第 10 节。 + - Rust 工作区共约 23,500 行 Rust:`crates/java-domain`(领域规则,不依赖宿主)和 `backend`(基于已固定版本 SDK 的插件后端进程)。 + - 312 个测试:领域 crate 280 个,后端 32 个(单元 23 个、`repository_pins` 6 个、`session` 3 个)。全部是有界、确定性的: + 不联网,不读全局时间,不占固定端口。其中跨插件函数相关的 30 个(`jdt_contributions.rs` 10 个、`plugin_functions.rs` 20 个) + 只验证插件内部逻辑,对照的仍是旧草稿里的中转形状,见第 10 节。 - 18 个 fixture(共 1,234 行)放在 `fixtures/`。其中 12 个被 Rust 测试读取;另外 6 个是从主仓库复制来的契约样例, 暂时没有 Rust 测试读取(见第 7 节)。 - 插件包:`io.github.lithe-idea.java-support/`,加上组装、校验、归档脚本、CI 和发布工作流。 @@ -53,9 +53,10 @@ 否则调用链 A→B→A 会被 Core 当作环路拒绝。 - 信任不传递:被调方用自己的权限执行,调用方不获得任何权限。 -中转本身由主仓库 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 在 Core 里实现。本仓库实现了 Java 一侧:接口常量、答案校验与合并(`jdt_contributions.rs`),以及发现并调用提供方的 -后端模块(`plugin_functions.rs`,中转协议暂按设计简报写,集中在这一个文件里,见第 5 节)。中转没有合并之前这些代码不会被宿主调用, -缺的部分在代码里写 `TODO(host-api: API-35 跨插件函数)`。 +中转本身已由主仓库 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 在 Core 里实现并合入。本仓库实现了 Java 一侧:接口常量、答案校验与合并(`jdt_contributions.rs`),以及发现并调用提供方的 +后端模块(`plugin_functions.rs`,集中了全部中转协议细节,见第 5 节)。这个模块仍按合入前的设计草稿写,与已合入的方法形状不同; +中转方法属于插件函数协议 4,而 Rust SDK 0.2.0 只协商协议 1–3,所以要等支持协议 4 的 SDK 发布后一起对齐。 +在那之前这些代码不会被宿主调用,缺的部分在代码里写 `TODO(host-api: API-35 跨插件函数)`。 ### 2.3 计划编号约定 @@ -121,8 +122,8 @@ API-30(调试适配器)、API-37(环境与 l10n)、API-42(内置 Git | `…/project/{maven,maven_dependency_tree,maven_test_reports}.rs@1daaeb525` | 无 | 不移植(Maven 插件) | Windows 仍在用,见第 8 节 | | `…/build/language_import.rs@1daaeb525`、`build/integrations/{mod,maven}.rs@1daaeb525` | 无 | 不移植(Maven/Gradle 插件,被 API-35 取代) | 见第 4 节 | | `…/build/language_import.rs@1daaeb525` 的调用方一侧(启动 JDT LS 前收集各构建系统的导入设置) | `dj/lsp/languages/jdt_contributions.rs`(新写) | 已移植并有测试 | 10 个测试。接口常量 `languageImport.settings` 与 `languageImport.settingsChanged`、答案校验、按提供方分槽的合并与状态;不含任何 Maven/Gradle 逻辑,见第 5 节 | -| 同上(跨进程调用部分) | `backend/src/plugin_functions.rs`(新写) | 已移植并有测试 | 14 个测试。经 Core 中转发现并调用提供方,全部用桩中转测试;宿主方法名与形状是暂定的,见第 5.7 节 | -| `…/lsp/interface/engine.rs@1daaeb525`:`update_maven_configuration` 的接收一侧(Maven 设置变化后通知 Java) | `languageImport.settingsChanged` 的常量与分槽逻辑已在 `jdt_contributions.rs`;函数注册还没有 | 等待宿主 API(API-35) | SDK 0.1.0 不告诉处理函数调用方是哪个插件,而分槽需要可信的提供方 ID,见第 5.7 节 | +| 同上(跨进程调用部分) | `backend/src/plugin_functions.rs`(新写) | 已移植并有测试 | 20 个测试。经 Core 中转发现并调用提供方,全部用桩中转测试;宿主方法名与形状仍是合入前的草稿,要随支持协议 4 的 SDK 对齐,见第 5.7 节 | +| `…/lsp/interface/engine.rs@1daaeb525`:`update_maven_configuration` 的接收一侧(Maven 设置变化后通知 Java) | `languageImport.settingsChanged` 的常量与分槽逻辑已在 `jdt_contributions.rs`;函数注册还没有 | 等待宿主 API(API-35) | 中转已在 Lithe [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 合入,调用方插件 ID 在插件函数协议 4 的 `caller` 字段里;Rust SDK 0.2.0 只协商协议 1–3,所以还要等支持协议 4 的 SDK。分槽需要可信的提供方 ID,见第 5.7 节 | | `…/plugins/declarative.rs@1daaeb525`、`build/{invocation,manifest,detect,plans}.rs@1daaeb525` 里的 Java 特判(`KNOWN_RUNTIME_TOOLS*`、`HOST_INTEGRATION_*`、`ModelSource::HostIntegration`) | 无 | 不移植(宿主通用代码里的 Java 特判,不是插件逻辑) | 在主仓库删,见第 8 节 | | `…/protocol/cancellation.rs@1daaeb525` | `dj/protocol/cancellation.rs` | 已移植并有测试 | 8 个测试 | | `…/protocol/contracts.rs@1daaeb525`(Java、Spring、MyBatis 模型) | `dj/protocol/contracts.rs` | 已移植无测试 | 只复制 Java 相关请求与响应类型,由各命令的测试间接覆盖;`Maven*Response` 不复制 | @@ -132,7 +133,7 @@ API-30(调试适配器)、API-37(环境与 l10n)、API-42(内置 Git | `…/protocol/command.rs@1daaeb525`:`maven.*`、`lsp.retryMavenProfiles`、`lsp.updateMavenConfiguration`、`build.languageImport` | 无 | 不移植(Maven/Gradle 插件) | | | `…/lib.rs@1daaeb525`:公开函数 `jdt_workspace_key` | `lsp.jdtWorkspaceKey` 命令(`dj/commands.rs`) | 已移植并有测试 | `tests/java_commands.rs` 里读 `jdt-workspace-key-v1.json` | | `…/lib.rs@1daaeb525`:公开函数 `settings_with_local_repository` | 无(交付目录,见第 4 节) | 不移植(Maven 插件) | | -| `…/diagnostics/catalog_generated.rs@1daaeb525` 里的 Java 日志事件 | 无;后端用 `Context::log` | 等待宿主 API(API-22†) | 插件日志通道就绪前,只有 `argfile.rs` 等少数位置留了 TODO | +| `…/diagnostics/catalog_generated.rs@1daaeb525` 里的 Java 日志事件 | 后端经 Rust SDK 0.2.0 接入宿主统一日志 `litheLogging` 1:`backend/src/lib.rs`(`KEY_OPERATIONS` 与成功抑制)、`backend/src/plugin_functions.rs`(中转降级类别)、`dj/host/diagnostics.rs`(清理失败) | 已移植并有测试 | 删除了 `Context::log` 和所有 stderr 日志。级别、类别和内容见 README 的“日志”一节;SDK 0.2.0 要求宿主提供 `litheLogging` 1,见第 9.2、9.3 节 | | Swift `MacJavaLaunchArgumentPreparer` 的规则部分(Core 里没有) | `dj/execution/argfile.rs` | 已移植并有测试 | 4 个测试:只在命令过长时写 argfile,仅属主可读,租约释放时删除 | ### 3.2 Rust Core 测试与 fixture @@ -283,8 +284,8 @@ API-30(调试适配器)、API-37(环境与 l10n)、API-42(内置 Git ## 5. 跨插件函数设计(API-35) Java 一侧已经实现:接口常量、答案校验与合并在 `dj/lsp/languages/jdt_contributions.rs`(纯逻辑,不依赖 SDK), -发现并调用提供方在 `backend/src/plugin_functions.rs`。宿主中转由 Core 的 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 实现,在它合并之前,下面标"暂定"的协议形状可能改, -缺失的宿主能力在代码里是 `TODO(host-api: API-35 跨插件函数)`。 +发现并调用提供方在 `backend/src/plugin_functions.rs`。宿主中转已由 Core 的 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 实现并合入; +本仓库的协议形状仍是合入前的草稿,与已合入形状的差异见第 5.7 节,缺失的宿主能力在代码里是 `TODO(host-api: API-35 跨插件函数)`。 ### 5.1 接口函数 ID @@ -304,8 +305,8 @@ Java 一侧已经实现:接口常量、答案校验与合并在 `dj/lsp/langua 2. 向宿主发现已启用且导出 `languageImport.settings` 的插件。结果按插件 ID 排序、去重,最多取 16 个(`MAX_PROVIDERS`), 避免插件很多时拖慢 JDT LS 启动。 3. 逐个调用,每个提供方最多等 10 秒(`PROVIDER_TIMEOUT`,宿主另有自己的上限)。超时只取消那一次请求,不影响其他提供方。 -4. 每个答案交给 `accept_answer`:落在 `CONTRIBUTED_SETTINGS_KEYS` 里的叶子键被采用,其余记入 `rejected`,只把键名写进日志, - 永远不会到达 JDT LS。 +4. 每个答案交给 `accept_answer`:落在 `CONTRIBUTED_SETTINGS_KEYS` 里的叶子键被采用,其余记入 `rejected`,永远不会到达 JDT LS; + 日志里只上报类别 `relay.keys_rejected` 和计数,不写键名。 5. 合并按插件 ID 排序,叶子键相同时后者覆盖前者。合并结果(`Contributions::fragments`)放进 `JdtSettings::contributed`,再启动 JDT LS。 ### 5.3 超时、失败与状态 @@ -316,7 +317,8 @@ Java 一侧已经实现:接口常量、答案校验与合并在 `dj/lsp/langua `Failed`(有提供方失败、超时、答案不可用,或宿主根本无法列出提供方)。`Pending` 时 `java_build` 的阻塞项是 `ContributedSettings` (`waitingForContributedSettings`),和原来等待 Maven profiles 的位置相同。 - 状态行里的失败原因只带稳定的错误码(例如"The call failed (plugin.host.timeout)."),不带宿主返回的消息,因为消息可能带路径; - 详细消息只写 stderr 日志。 + 详细消息(`details`)不离开后端,失败只以 `pluginErrorCode` 为类别经 `litheLogging` 1 上报; + 中转降级另有 `relay.*` 类别。级别与类别见 README 的“日志”一节。 - 不在 `languageImport.settings` 的调用里回调 Maven,避免调用链环路。 ### 5.4 Maven profiles 缺口 @@ -341,27 +343,33 @@ Java 的编辑器标记(API-27)被点击后,Java 后端只产生测试选 Java 后端向宿主查询导出测试运行函数 ID 的已启用插件,把选择器发过去,不写死 Maven 或 Gradle。结果显示在宿主原生测试面板的落点, 等 API-27 的编辑器动作 API 就位后确定。 -### 5.7 中转协议形状(暂定)与清单声明 +### 5.7 中转协议形状:草稿与已合入版本的差异,以及清单声明 -`backend/src/plugin_functions.rs` 是后端里唯一知道中转协议的地方。方法名与形状按设计简报写,Core 的中转 PR [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 还没定稿; -改名或改形状只动这个文件里的两个方法名常量、两个参数构造函数和两个结果解析函数,收集逻辑和它的测试不变。 +`backend/src/plugin_functions.rs` 是后端里唯一知道中转协议的地方。它仍按合入前的设计草稿写;Core 的中转 +[#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 已经合入,方法名相同,参数和结果形状不同(以主仓库 +`shared/contracts/plugins/v1/README.md` 的 “Discovery and invocation” 一节为准): -| 宿主方法 | 参数 | 结果 | +| 宿主方法 | 本仓库草稿 | 已合入的形状 | | --- | --- | --- | -| `lithe/host/functions/providers` | `{ context, functionID, version }` | `{ providers: [{ pluginID }] }` | -| `lithe/host/functions/invoke` | `{ context, pluginID, functionID, version, params }` | `{ result }` | +| `lithe/host/functions/providers` | 参数 `{ context, functionID, version }`,结果 `{ providers: [{ pluginID }] }` | 参数 `{ functionID }`,结果 `{ providers: [{ pluginID, version }] }`,按插件 ID 排序、不含调用方自己 | +| `lithe/host/functions/invoke` | 参数 `{ context, pluginID, functionID, version, params }`,结果 `{ result }` | 参数 `{ pluginID, functionID, context: { contextID, generation }, params?, timeoutMilliseconds?, parentOperationID? }`,结果 `{ value }`;未定义的字段是 `-32018`(`plugin.host.unsupportedOption`) | -`context` 是当前调用所在窗口的 workspace context。收集逻辑只依赖 `FunctionRelay` 这个 trait,测试用桩实现,不连宿主。 +两个方法都只对协商到插件函数协议 4 的后端开放,协议 3 及以下得到 `-32601`。Rust SDK 0.2.0 只协商协议 1–3, +所以对齐要等支持协议 4 的 SDK:届时只改这个文件里的两个方法名常量、两个参数构造函数和两个结果解析函数, +版本检查改为读取 `providers` 结果里的 `version`,嵌套调用时带上 `parentOperationID`;收集逻辑和它的测试不变。 +收集逻辑只依赖 `FunctionRelay` 这个 trait,测试用桩实现,不连宿主。 -按设计简报,Java 的清单将来要这样声明(具体写法以中转 PR 合并后的契约 README 为准;现在 `plugin.json` 没有 `backend`,所以还没有写): +Java 的清单将来要这样声明(具体写法以已合入的契约 README 为准;现在 `plugin.json` 没有 `backend`,所以还没有写): - 调用方声明 `languageImport.settings`(版本 1); - 导出 `languageImport.settingsChanged`(版本 1,`context: "workspace"`); -- 需要 `pluginFunctions` 能力 1、后端协议 2,以及不低于 3 的插件 API 级别。 +- `requires` 写 `pluginFunctions: 1`、`backend: 2` 和 `serverProcess: 1`,`pluginAPIVersion` 不低于 3; + 后端运行时还要协商到插件函数协议 4 才能调用中转方法。 -**为什么变化通知函数还没有注册。** 槽位是按提供方分的:一次变化调用只替换发送者自己的那一份。可信的发送者只能来自宿主, -而 SDK 0.1.0 没有把调用方插件 ID 交给处理函数。如果从调用参数里读一个"我是谁",任何插件都能覆盖别人的设置。 -所以 `Contributions::record_settings`、`accept_answer` 和 `plugin_functions::record_answer` 已就位,等 SDK 或中转暴露调用方之后, +**为什么变化通知函数还没有注册。** 槽位是按提供方分的:一次变化调用只替换发送者自己的那一份。可信的发送者只能来自宿主。 +中转已在 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 合入,调用方插件 ID 由宿主放在插件函数协议 4 的 `caller` 字段里; +Rust SDK 0.2.0 只协商协议 1–3,处理函数拿不到这个字段。如果从调用参数里读一个"我是谁",任何插件都能覆盖别人的设置。 +所以 `Contributions::record_settings`、`accept_answer` 和 `plugin_functions::record_answer` 已就位,等有支持协议 4 的 SDK 之后, 注册函数只需几行。这一项记为 `TODO(host-api: API-35 跨插件函数)`。 **信任边界。** 宿主只中转,不传递信任:Maven/Gradle 插件用自己的权限执行,Java 不因此获得权限;Java 只接受 `CONTRIBUTED_SETTINGS_KEYS` @@ -413,8 +421,9 @@ Java 后端向宿主查询导出测试运行函数 ID 的已启用插件,把 ### API-35 跨插件函数 -- `backend/src/plugin_functions.rs`:中转协议(方法名和形状)是暂定的,等 Core 的中转 PR [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 定稿(第 5.7 节)。 -- `backend/src/plugin_functions.rs`:`languageImport.settingsChanged` 还没有注册,等 SDK 或中转暴露调用方插件 ID(第 5.7 节)。 +- `backend/src/plugin_functions.rs`:中转形状仍是合入前的草稿,要按已合入的 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 对齐,前提是支持插件函数协议 4 的 SDK(第 5.7 节)。 +- `backend/src/plugin_functions.rs`:`languageImport.settingsChanged` 还没有注册。中转已合入,调用方插件 ID 在插件函数协议 4 的 `caller` 字段里, + Rust SDK 0.2.0 只协商协议 1–3,要等支持协议 4 的 SDK(第 5.7 节)。 - `dj/lsp/languages/jdt.rs`:没有中转时 `CONTRIBUTED_SETTINGS_KEYS` 的贡献者不会出现(第 5.1 到 5.3 节)。 - `dj/lsp/languages/jdt.rs`:Maven profiles 缺口(第 5.4 节)。 - `dj/lsp/languages/jdt.rs`:`ContributedSettingsStatus` 的 `Pending` 由调用方在收集期间持有,变化后的 `workspace/didChangeConfiguration` @@ -425,7 +434,7 @@ Java 后端向宿主查询导出测试运行函数 ID 的已启用插件,把 ### 来自计划里其他编号的占位(`†`,不在指定的五个之内) - API-20 存储:`dj/execution/argfile.rs`(后端没有插件自有的临时目录)、`dj/jdt_integration/workspace_state.rs`(缓存目录来自宿主)。 -- API-22 日志与进度:`dj/host/storage.rs`、`dj/execution/argfile.rs`(插件日志通道)。 +- API-22 日志与进度:日志部分已完成,后端接入 `litheLogging` 1,不再有占位(第 3.1 节“Java 日志事件”一行,级别表见 README 的“日志”一节)。 - API-23 工具链与进程:`dj/jdt_integration/runtime.rs`(探测 `java -version` 的进程执行)。 - API-37 环境与 l10n:`dj/jdt_integration/runtime.rs`、`dj/jdt_integration/launch_resources.rs`、`backend/src/lib.rs`(用户可见文案要走宿主 i18n)。 - API-42 内置 Git 只读:`dj/host/git.rs`、`dj/languages/code_vision_hints.rs`(blame 作者)、`dj/lsp/languages/jdt_project_metadata.rs`(一条测试注释,Git 忽略查询)。 @@ -458,7 +467,7 @@ Java 后端向宿主查询导出测试运行函数 ID 的已启用插件,把 - Core 的跨插件函数中转(API-35)合并,Maven/Gradle 插件导出 `languageImport.settings`。 - 宿主的语言服务器通道(API-25)、编辑器标记(API-27)、运行与测试(API-29)合并,Java 插件声明 `backend` 并通过 e2e。 - 本仓库发布一个支持 API 4 的预发布版本(普通插件,不带发布者签名);转为正式发布要等第 8.2 节列出的两条宿主限制取消、 - 主仓库不再内置 Java Support(见第 9 节)。 + 主仓库不再内置 Java Support、Lithe 稳定版提供 `litheLogging` 1(见第 9 节)。 ### 8.2 macOS 阶段(阶段 E):Core 与 macOS 应用 @@ -527,12 +536,13 @@ Java 后端向宿主查询导出测试运行函数 ID 的已启用插件,把 加 `.sha256`,由 `scripts/archive-package.sh` 用 `ditto --keepParent` 制作;本地开发、CI 和发布用的是同一个脚本、同一种归档。 - `Release` 工作流先在 Ubuntu 上跑 Rust 检查,再在 macOS 上构建、校验并归档 arm64 与 x86_64 两个包,最后发布。没有任何步骤读取签名密钥。 - CI 的 `package` 任务(macOS,草稿 PR 不跑)构建并归档同样的两个架构,把 `dist/` 作为 `java-support-archives` 上传。 -- SDK 固定在 `sdk.lock.json`(`lithe_plugin_sdk` 0.1.0,标签 `sdk-rust-v0.1.0`,提交 `e689971c000c15a044df0502052e7b2c1c6f84f`), - `scripts/verify-sdk-lock.sh` 检查 `Cargo.lock` 解析到的是同一个提交。 +- SDK 固定在 `sdk.lock.json`(`lithe_plugin_sdk` 0.2.0,标签 `sdk-rust-v0.2.0`,提交 `940394fcd8455186ee985e6c34a319032414607f`), + `scripts/verify-sdk-lock.sh` 检查 `Cargo.lock` 解析到的是同一个提交。0.2.0 起 SDK 要求宿主提供 `litheLogging` 1: + 不提供的 Lithe 版本会拒绝启动后端。目前只有 Lithe 最新 `preview` 提供,稳定版还没有。日志的级别与类别见 README 的“日志”一节。 ### 9.3 为什么仍是预发布 -发布保持为预发布(`--prerelease`),商店的跟踪器会跳过预发布,所以目前不会上架。原因有两个: +发布保持为预发布(`--prerelease`),商店的跟踪器会跳过预发布,所以目前不会上架。原因有三个: 1. 主仓库仍然以旧 ID `dev.lithe.plugin.java-support` 内置 Java Support(`Plugins/marketplace/official-plugins.json` 里的条目 `releaseEnabled: false`,`bundledUntilPublishedVersion: "1.1.0"`)。 @@ -541,9 +551,12 @@ Java 后端向宿主查询导出测试运行函数 ID 的已启用插件,把 错误 `plugin.integration.unsupported`); - 短 `toolDefinitions` ID(例如 `java`)只在宿主验证过官方来源时才接受(`rust/lithe-core/src/toolchain/definitions.rs`)。 本包两者都用到了。 +3. 后端链接的 Rust SDK 0.2.0 需要宿主提供 `litheLogging` 1,不提供的 Lithe 版本会拒绝启动后端。目前只有 Lithe 最新 `preview` 提供, + 稳定版还没有,所以在 Lithe 稳定版提供它之前,Release 保持预发布。 转为正式发布的条件:主仓库完成阶段 E(第 8.2 节),用通用 API 取代 JDT 宿主集成,给 `java` 工具 ID 一条不依赖官方身份的规则 -(Maven 插件依赖工具 ID `java`),并且不再内置 Java Support。之后在 `release.yml` 里去掉 `--prerelease`,商店自动上架稳定版。 +(Maven 插件依赖工具 ID `java`),不再内置 Java Support,并且 Lithe 稳定版已经提供 `litheLogging` 1。 +之后在 `release.yml` 里去掉 `--prerelease`,商店自动上架稳定版。 下载的 ZIP 通过 设置 → 插件 的 ⚙ 菜单 → 从磁盘安装插件… 安装,但在此之前当前宿主会按上面的原因拒绝它。 阶段 E 还要退役内置的 `dev.lithe.plugin.java-support` 条目并把用户迁移到新 ID,见第 8.2 节。 @@ -557,8 +570,8 @@ Java 后端向宿主查询导出测试运行函数 ID 的已启用插件,把 ## 10. 未完成事项 - 宿主没有接线:`plugin.json` 没有 `backend` 声明,第 6 节的 API 全部没有实现,三个会话命令只回答 `NotSupported`。 -- API-35 的 Java 一侧大部分已实现(第 5 节),但中转协议是暂定的;变化通知函数没有注册;没有任何地方调用 `collect_for_call`(要等 API-25 的启动解析器)。Maven profiles 的逐工程交付形状没有定。 -- `jdt_contributions.rs` 和 `plugin_functions.rs` 的 24 个测试只验证了插件内部逻辑:它们已经编译并通过,但用来对照的中转协议是暂定的,等 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 合入后要按最终协议复核。 +- API-35 的 Java 一侧大部分已实现(第 5 节),但中转形状仍是合入前的草稿,要等支持插件函数协议 4 的 SDK 再对齐;变化通知函数没有注册;没有任何地方调用 `collect_for_call`(要等 API-25 的启动解析器)。Maven profiles 的逐工程交付形状没有定。 +- `jdt_contributions.rs` 和 `plugin_functions.rs` 的 30 个测试只验证了插件内部逻辑:它们已经编译并通过,但对照的是草稿形状。[#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 已合入,等 SDK 支持协议 4、形状对齐后要按已合入的协议复核。 - 没有真实 JDT 的端到端测试,也没有在 Linux 上实跑过(本机没有 Docker)。 - CI 的 `package` 任务构建的归档和正式发布的是同一种(同一组脚本、同一个文件名规则),但还没有真正跑过一次发布工作流: 新 ID 下没有发布过任何版本,标签触发、预发布 Release 的创建和资产上传都没有被实际验证过。 diff --git a/README.md b/README.md index aa2fe1f..a5ad2dc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ ## 工作方式 - **领域规则**在 `crates/java-domain`,不依赖宿主:命令的 JSON 载荷和错误码与主仓库 Core 一一对应。 - 所有宿主差异(存储、日志、Git、语言服务器通道)都是带 `TODO(host-api: API-NN)` 的占位。 + 所有宿主差异(存储、Git、语言服务器通道)都是带 `TODO(host-api: API-NN)` 的占位;日志已接入宿主统一日志,见“日志”一节。 - **插件后端**在 `backend/`:基于固定版本的 Rust SDK(`sdk.lock.json`),把每条领域命令注册为同名插件函数, 并带着跨插件函数的调用方一侧。 - **跨插件函数**:启动 JDT LS 之前,后端通过 Core 中转发现所有导出 `languageImport.settings` 的插件(Maven、Gradle), @@ -41,6 +41,23 @@ - **运行时只读**:插件包安装后在运行时只读。JDT LS、Lombok、java-debug、测试运行器和 JDK 都在构建阶段固定来源、校验哈希后放进包里, 运行时状态只写宿主提供的状态目录,不写包目录。 +## 日志 + +后端把日志交给宿主的统一日志 `litheLogging` 1,不写 stderr。Rust SDK 0.2.0 要求宿主提供 `litheLogging` 1, +不提供的 Lithe 版本会拒绝启动后端;目前只有 Lithe 最新 `preview` 提供,稳定版还没有。 + +| 事实 | 级别 | 来源 | 内容 | +| --- | --- | --- | --- | +| 关键操作成功:`runConfig.selectJava`、`debug.javaTestLaunch`、`execution.planLaunchCommand`、`java.prepareLaunch`(`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`)、耗时;不含消息与 `details` | +| 取消 | 不记录 | 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`,计数;不含提供方 ID | +| 插件存储清理失败 | WARN(`degraded`) | `crates/java-domain/src/host/diagnostics.rs`(`host::diagnostics`) | 类别 `storage.cleanup_failed.<阶段>`(阶段为 `jdt-configuration`、`jdt-settings`、`java-argfile-remove`),计数 | +| JDT LS 自身日志 | 上游,默认 WARNING | `plugin.json` 的 `-Dosgi.logfile=${logDir}/jdt.log` | 只写宿主提供的会话日志目录,单文件 1 MB、保留 2 个备份;用户可在设置里调 `logLevel` | + +日志事实只带类别、计数和耗时,不含路径、源码、工具输出或用户内容。`${logDir}` 由宿主经可写存储适配器提供,不写插件包或 app bundle。 + ## 安装 Lithe 不再区分官方插件和普通插件,所有插件都按普通插件处理:本插件不带发布者签名(`plugin.json` 的 `vendor.signatureRequirement` 为 `none`)。 @@ -103,9 +120,11 @@ scripts/archive-package.sh --package <上一步打印的目录> --arch arm64 # 3. `Release` 工作流先在 Ubuntu 上跑 Rust 检查,再在 macOS 上构建、校验并归档 arm64 与 x86_64 两个包,然后发布。 发布资产是 `Lithe-Java-Support-<版本>-darwin-{arm64,x64}.zip` 和对应的 `.sha256`。没有任何步骤读取签名密钥,包也不带发布者签名。 -4. 发布保持为预发布(`--prerelease`):Lithe Store 的自动跟踪会跳过预发布,而目前的 Lithe 一是仍然内置旧 ID 的 Java Support, - 二是只允许 `dev.lithe.*` 插件使用 JDT 宿主集成、只对带官方来源的包接受短工具 ID `java`。等 Lithe 主仓库的阶段 E 完成 - (`MIGRATION.md` 第 8.2 节)后,去掉 `--prerelease`,商店就会自动上架稳定版。细节见 `MIGRATION.md` 第 9 节。 +4. 发布保持为预发布(`--prerelease`):Lithe Store 的自动跟踪会跳过预发布,原因有三个。目前的 Lithe 一是仍然内置旧 ID 的 Java Support, + 二是只允许 `dev.lithe.*` 插件使用 JDT 宿主集成、只对带官方来源的包接受短工具 ID `java`, + 三是后端链接的 Rust SDK 0.2.0 需要宿主提供 `litheLogging` 1,目前只有 Lithe 最新 `preview` 提供,稳定版还没有。 + 等 Lithe 主仓库的阶段 E 完成(`MIGRATION.md` 第 8.2 节)、并且 Lithe 稳定版提供 `litheLogging` 1 后,去掉 `--prerelease`, + 商店就会自动上架稳定版。细节见 `MIGRATION.md` 第 9 节。 ## 许可 diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c09692d..78c6a6f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -15,5 +15,5 @@ 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.1.0" } +lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs", tag = "sdk-rust-v0.2.0" } serde_json.workspace = true diff --git a/backend/src/lib.rs b/backend/src/lib.rs index abc2e8f..a758564 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -17,19 +17,47 @@ //! `languageImport.settings` (API-35): it asks the Maven and Gradle plugins //! for the settings JDT LS imports with. It is the only module that knows the //! host relay's wire. +//! +//! # Logging +//! +//! The backend reports restricted facts through the SDK's `litheLogging` 1 +//! 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 +//! `operationFinished` except for the functions outside [`KEY_OPERATIONS`], +//! whose success is suppressed. Failures the domain layer handled and +//! recovered from are reported as `degraded` through +//! `lithe_java_domain::host::diagnostics`. The backend never writes stderr +//! logs, and a fact carries no path, source or tool output. pub mod plugin_functions; use lithe_java_domain::commands::{self, ExecuteOptions, SESSION_COMMANDS, SUPPORTED_COMMANDS}; +use lithe_java_domain::host::diagnostics::{scoped, Reporter}; use lithe_java_domain::protocol::{CoreError, ErrorCode}; use lithe_plugin_sdk::{Backend, Context, FunctionError, PluginError, SdkError, Value}; +use std::rc::Rc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; /// Name reported as `serverInfo.name`. pub const BACKEND_NAME: &str = "lithe-java-backend"; +/// Functions whose success is a key user operation and is logged at INFO. +/// Every other function is an editor-driven or startup read; its success is +/// suppressed, and its failures are still logged. +pub const KEY_OPERATIONS: &[&str] = &[ + "debug.javaTestLaunch", + "execution.planLaunchCommand", + "java.prepareLaunch", + "runConfig.selectJava", +]; + /// Builds the backend with every Java Support function registered. +/// +/// A function can only be suppressed once it is registered, so the successes +/// of the functions outside [`KEY_OPERATIONS`] are suppressed after the +/// registration loop. pub fn backend() -> Result { let mut backend = Backend::new(BACKEND_NAME).version(env!("CARGO_PKG_VERSION")); for command in SUPPORTED_COMMANDS.iter().chain(SESSION_COMMANDS) { @@ -38,6 +66,11 @@ pub fn backend() -> Result { invoke(command, params, context) })?; } + for command in SUPPORTED_COMMANDS.iter().chain(SESSION_COMMANDS) { + if !KEY_OPERATIONS.contains(command) { + backend.suppress_success_diagnostics(command)?; + } + } Ok(backend) } @@ -62,34 +95,41 @@ fn invoke(command: &str, params: Value, context: &Context) -> Result FunctionError { +/// The SDK reports the failure once, at the function's result boundary, with +/// the `pluginErrorCode` as its category, so this function logs nothing. +/// `details` may carry paths or tool output and never leaves the backend: it +/// is neither part of the error nor part of any log fact. +fn function_error(error: CoreError) -> FunctionError { if error.code == ErrorCode::Cancelled { return FunctionError::Cancelled; } - if let Some(details) = &error.details { - context.log(format!( - "{} failed: {}: {details}", - context.function_id(), - error.message - )); - } // TODO(host-api: API-37 i18n): the messages are English Core messages. // Localize them through the host i18n service once plugin backends can // reach it. @@ -149,4 +189,25 @@ mod tests { } assert!(backend().is_ok()); } + + #[test] + fn key_operations_are_registered_commands_without_duplicates() { + let commands: std::collections::BTreeSet<&str> = SUPPORTED_COMMANDS + .iter() + .chain(SESSION_COMMANDS) + .copied() + .collect(); + let distinct: std::collections::BTreeSet<&str> = KEY_OPERATIONS.iter().copied().collect(); + assert_eq!( + distinct.len(), + KEY_OPERATIONS.len(), + "a key operation is listed twice" + ); + for operation in KEY_OPERATIONS { + assert!( + commands.contains(operation), + "{operation} is not a registered command" + ); + } + } } diff --git a/backend/src/main.rs b/backend/src/main.rs index b861629..e792f71 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,12 +1,18 @@ //! Entry point of the Java Support plugin backend. //! -//! Stdout carries only protocol frames; diagnostics go to stderr. +//! Stdout carries only protocol frames. Diagnostics are sent through the +//! host's `litheLogging` channel, never printed. Stderr gets a single fixed +//! line with the SDK error code, and only when registering the functions +//! fails before the protocol starts. fn main() { let backend = match lithe_java_backend::backend() { Ok(backend) => backend, Err(error) => { - eprintln!("lithe-java-backend: could not register functions: {error}"); + eprintln!( + "lithe-java-backend: could not register functions ({})", + error.code() + ); std::process::exit(1); } }; diff --git a/backend/src/plugin_functions.rs b/backend/src/plugin_functions.rs index ff549f0..a578c81 100644 --- a/backend/src/plugin_functions.rs +++ b/backend/src/plugin_functions.rs @@ -13,26 +13,30 @@ //! answers in [`Contributions`]. The interface itself is defined by //! `lithe_java_domain::lsp::languages::jdt_contributions`. //! -//! TODO(host-api: API-35 cross-plugin function): the relay is built in a -//! separate Lithe change and its wire is not final. The method names and -//! shapes below follow the design brief: +//! TODO(host-api: API-35 cross-plugin function): the relay was merged in +//! Lithe #1742 and its real wire differs from the shapes this module still +//! uses, so the module cannot call it yet: //! -//! - `lithe/host/functions/providers` takes `{ context, functionID, version }` -//! and answers `{ providers: [{ pluginID }] }` for the enabled plugins that -//! export that function, sorted by plugin ID. +//! - `lithe/host/functions/providers` takes `{ functionID }` and answers +//! `{ providers: [{ pluginID, version }] }`. //! - `lithe/host/functions/invoke` takes -//! `{ context, pluginID, functionID, version, params }` and answers -//! `{ result }`. +//! `{ pluginID, functionID, context: { contextID, generation }, params?, +//! timeoutMilliseconds?, parentOperationID? }` and answers `{ value }`. +//! Fields the host does not define are refused with +//! `plugin.host.unsupportedOption`. //! -//! Adjust the constants and the two `*_params` / parse functions when the -//! relay lands; the collector and its tests do not change. +//! Both methods require plugin function protocol 4, while Rust SDK 0.2.0 +//! negotiates only protocols 1 to 3. The method constants and the two +//! `*_params` / parse functions therefore change together with an upgrade to +//! an SDK that speaks protocol 4; the collector and its tests do not change. //! //! TODO(host-api: API-35 cross-plugin function): the change function //! `languageImport.settingsChanged` is not registered yet. It must store the -//! new settings in the slot of the *calling plugin*, and the SDK this crate is -//! pinned to does not tell a handler which plugin called it. Register it -//! with `jdt_contributions::SETTINGS_CHANGED_FUNCTION` and [`record_answer`] -//! once the SDK exposes the caller. +//! new settings in the slot of the *calling plugin*. Protocol 4 names the +//! caller in the `caller` field of an invocation, and SDK 0.2.0 does not +//! expose that field to a handler. Register the function with +//! `jdt_contributions::SETTINGS_CHANGED_FUNCTION` and [`record_answer`] once +//! the SDK exposes the caller. use lithe_java_domain::lsp::languages::jdt_contributions::{ accept_answer, settings_request, Contributions, FunctionContract, MAX_PROVIDERS, @@ -56,12 +60,38 @@ pub const INVOKE_METHOD: &str = "lithe/host/functions/invoke"; /// The host applies its own, larger limit on top. pub const PROVIDER_TIMEOUT: Duration = Duration::from_secs(10); +/// Category reported when the host could not list the settings providers. +pub const RELAY_PROVIDERS_UNAVAILABLE: &str = "relay.providers_unavailable"; + +/// Category reported when a call to one settings provider failed. +pub const RELAY_PROVIDER_FAILED: &str = "relay.provider_failed"; + +/// Category reported when a call to one settings provider timed out. +pub const RELAY_PROVIDER_TIMED_OUT: &str = "relay.provider_timed_out"; + +/// Category reported when a provider's answer was unusable and was dropped. +pub const RELAY_SETTINGS_REJECTED: &str = "relay.settings_rejected"; + +/// Category reported when an answer carried keys outside the accepted +/// namespace. The count is the number of rejected keys. +pub const RELAY_KEYS_REJECTED: &str = "relay.keys_rejected"; + +/// Every category this module reports. Each one is a controlled code that +/// names no provider, path or message. +pub const RELAY_CATEGORIES: &[&str] = &[ + RELAY_PROVIDERS_UNAVAILABLE, + RELAY_PROVIDER_FAILED, + RELAY_PROVIDER_TIMED_OUT, + RELAY_SETTINGS_REJECTED, + RELAY_KEYS_REJECTED, +]; + /// A relay request that was refused or failed. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelayFailure { /// Stable code, such as `plugin.host.timeout` or an `sdk.*` code. pub code: String, - /// Diagnostic text. It may name paths, so it is only logged. + /// Diagnostic text. It may name paths, so it never leaves the backend. pub message: String, } @@ -233,12 +263,14 @@ impl Drop for Deadline { /// A provider that fails, times out or answers with something unusable is /// recorded as failed and does not stop the others, so JDT LS can always /// start. The only error is the calling function's own cancellation. -/// `log` receives diagnostics for stderr. +/// `report` receives the category and count of every failure that was handled +/// this way (one of [`RELAY_CATEGORIES`]), and never a provider ID, a path or +/// a message. pub fn collect_contributions( relay: &dyn FunctionRelay, workspace_root: &str, cancellation: &Cancellation, - log: &dyn Fn(&str), + report: &dyn Fn(&str, u64), ) -> Result { let mut contributions = Contributions::default(); cancellation.check()?; @@ -246,10 +278,7 @@ pub fn collect_contributions( Ok(providers) => providers, Err(failure) if failure.is_cancelled() => return Err(Cancelled), Err(failure) => { - log(&format!( - "Listing settings providers failed: {}: {}", - failure.code, failure.message - )); + report(RELAY_PROVIDERS_UNAVAILABLE, 1); contributions.record_discovery_failure(failure_reason(&failure)); return Ok(contributions); } @@ -261,18 +290,16 @@ pub fn collect_contributions( cancellation.check()?; let request = settings_request(workspace_root); match relay.invoke(&provider, &SETTINGS_FUNCTION, request) { - Ok(answer) => record_answer(&mut contributions, &provider, &answer, log), + Ok(answer) => record_answer(&mut contributions, &provider, &answer, report), Err(failure) if failure.is_cancelled() => { // The calling function was cancelled, or only this request // ran out of time. cancellation.check()?; + report(RELAY_PROVIDER_TIMED_OUT, 1); contributions.record_failure(&provider, "The call timed out."); } Err(failure) => { - log(&format!( - "{provider} failed to provide settings: {}: {}", - failure.code, failure.message - )); + report(RELAY_PROVIDER_FAILED, 1); contributions.record_failure(&provider, failure_reason(&failure)); } } @@ -284,26 +311,25 @@ pub fn collect_contributions( /// /// An unusable answer marks the provider as failed instead of keeping what it /// sent before, so the status never claims settings that were not accepted. +/// `report` receives [`RELAY_SETTINGS_REJECTED`] for an unusable answer and +/// [`RELAY_KEYS_REJECTED`] with the number of keys dropped from a usable one; +/// it never receives the provider ID, the keys or the reason text. pub fn record_answer( contributions: &mut Contributions, provider: &str, answer: &Value, - log: &dyn Fn(&str), + report: &dyn Fn(&str, u64), ) { match accept_answer(answer) { Ok(accepted) => { if !accepted.rejected.is_empty() { - log(&format!( - "{provider} contributed keys outside the accepted namespace: {}", - accepted.rejected.join(", ") - )); + let rejected = u64::try_from(accepted.rejected.len()).unwrap_or(u64::MAX); + report(RELAY_KEYS_REJECTED, rejected); } contributions.record_settings(provider, accepted); } Err(reason) => { - log(&format!( - "{provider} answered with unusable settings: {reason}" - )); + report(RELAY_SETTINGS_REJECTED, 1); contributions.record_failure(provider, reason); } } @@ -331,12 +357,17 @@ pub fn collect_for_call( .into()); }; let relay = HostRelay::new(context.host(), workspace, PROVIDER_TIMEOUT); - let log = |message: &str| context.log(message); + let diagnostics = context.diagnostics(); + let operation = context.function_id(); + let report = |code: &str, count: u64| { + // An error only means the arguments were invalid; the call's own result stands. + let _ = diagnostics.degraded(operation, code, Some(count)); + }; Ok(collect_contributions( &relay, workspace_root, context.cancellation(), - &log, + &report, )?) } @@ -412,10 +443,21 @@ mod tests { } fn collect(relay: &FakeRelay) -> Contributions { - collect_contributions(relay, "/work/app", &Cancellation::new(), &|_| {}) + collect_contributions(relay, "/work/app", &Cancellation::new(), &|_, _| {}) .expect("the call is not cancelled") } + /// Collects with a reporter that records every category and count. + fn collect_reporting(relay: &FakeRelay) -> (Contributions, Vec<(String, u64)>) { + let reports = RefCell::new(Vec::<(String, u64)>::new()); + let contributions = + collect_contributions(relay, "/work/app", &Cancellation::new(), &|code, count| { + reports.borrow_mut().push((code.to_string(), count)) + }) + .expect("the call is not cancelled"); + (contributions, reports.into_inner()) + } + #[test] fn the_interface_functions_are_valid_function_ids() { use lithe_java_domain::lsp::languages::jdt_contributions::SETTINGS_CHANGED_FUNCTION; @@ -589,28 +631,165 @@ mod tests { } #[test] - fn rejected_keys_are_logged_by_name_and_never_stored() { + fn rejected_keys_are_reported_by_count_and_never_stored() { let relay = FakeRelay::new(&["io.github.lithe-idea.maven-build"]).answering( "io.github.lithe-idea.maven-build", settings(json!({ "java.import.maven.enabled": true, - "java.home": "/opt/other-jdk" + "java.home": "/opt/other-jdk", + "unrelated": 1 })), ); - let lines = RefCell::new(Vec::::new()); - let contributions = - collect_contributions(&relay, "/work/app", &Cancellation::new(), &|line| { - lines.borrow_mut().push(line.to_string()) - }) - .expect("the call is not cancelled"); + let (contributions, reports) = collect_reporting(&relay); assert_eq!( contributions.fragments(), [json!({ "java.import.maven.enabled": true })] ); - let lines = lines.borrow(); - assert_eq!(lines.len(), 1); - assert!(lines[0].contains("java.home"), "{}", lines[0]); - assert!(!lines[0].contains("/opt/other-jdk"), "{}", lines[0]); + // One report for the two rejected keys; neither key nor value is named. + assert_eq!(reports, [(RELAY_KEYS_REJECTED.to_string(), 2)]); + } + + #[test] + fn a_failed_discovery_is_reported_once_without_its_message() { + let mut relay = FakeRelay::new(&[]); + relay.providers = Err(RelayFailure::new( + "plugin.host.unavailable", + "No relay at /home/dev/relay.sock.", + )); + let (_, reports) = collect_reporting(&relay); + assert_eq!(reports, [(RELAY_PROVIDERS_UNAVAILABLE.to_string(), 1)]); + } + + #[test] + fn a_failed_and_a_timed_out_provider_are_reported_by_category() { + let relay = FakeRelay::new(&[ + "io.github.lithe-idea.gradle-build", + "io.github.lithe-idea.maven-build", + ]) + .answering( + "io.github.lithe-idea.gradle-build", + Err(RelayFailure::new( + "plugin.host.timeout", + "/home/dev/.gradle is slow", + )), + ) + // The request was cancelled but the calling function was not. + .answering("io.github.lithe-idea.maven-build", Err(cancelled())); + let (_, reports) = collect_reporting(&relay); + // Providers are called in plugin ID order: gradle first, then maven. + assert_eq!( + reports, + [ + (RELAY_PROVIDER_FAILED.to_string(), 1), + (RELAY_PROVIDER_TIMED_OUT.to_string(), 1), + ] + ); + } + + #[test] + fn an_unusable_answer_is_reported_as_rejected_settings() { + let relay = FakeRelay::new(&["io.github.lithe-idea.gradle-build"]).answering( + "io.github.lithe-idea.gradle-build", + Ok(json!({ "settings": "offline" })), + ); + let (_, reports) = collect_reporting(&relay); + assert_eq!(reports, [(RELAY_SETTINGS_REJECTED.to_string(), 1)]); + } + + #[test] + fn a_clean_collection_reports_nothing() { + let relay = FakeRelay::new(&["io.github.lithe-idea.maven-build"]).answering( + "io.github.lithe-idea.maven-build", + settings(json!({ "java.import.maven.enabled": true })), + ); + let (contributions, reports) = collect_reporting(&relay); + assert_eq!(contributions.status(), ContributedSettingsStatus::Settled); + assert!(reports.is_empty(), "{reports:?}"); + } + + #[test] + fn reports_never_name_a_provider_a_path_or_a_key() { + let relay = FakeRelay::new(&[ + "io.github.lithe-idea.gradle-build", + "io.github.lithe-idea.maven-build", + "io.github.lithe-idea.sbt-build", + "io.github.lithe-idea.zeta-build", + ]) + .answering( + "io.github.lithe-idea.gradle-build", + Err(RelayFailure::new( + "plugin.host.timeout", + "/home/dev/.gradle is slow", + )), + ) + .answering("io.github.lithe-idea.maven-build", Err(cancelled())) + .answering( + "io.github.lithe-idea.sbt-build", + Ok(json!({ "settings": "offline" })), + ) + .answering( + "io.github.lithe-idea.zeta-build", + settings(json!({ + "java.import.maven.enabled": true, + "java.home": "/opt/other-jdk", + "unrelated": 1 + })), + ); + let (_, reports) = collect_reporting(&relay); + assert_eq!( + reports, + [ + (RELAY_PROVIDER_FAILED.to_string(), 1), + (RELAY_PROVIDER_TIMED_OUT.to_string(), 1), + (RELAY_SETTINGS_REJECTED.to_string(), 1), + (RELAY_KEYS_REJECTED.to_string(), 2), + ] + ); + let forbidden = [ + "io.github", + "lithe-idea", + "gradle", + "maven", + "sbt", + "zeta", + "/home/dev", + "/opt/other-jdk", + "java.home", + "unrelated", + "plugin.host.timeout", + ]; + for (code, _) in &reports { + assert!(RELAY_CATEGORIES.contains(&code.as_str()), "{code}"); + for fragment in forbidden { + assert!(!code.contains(fragment), "{code} names {fragment}"); + } + } + } + + /// A byte the host accepts in a diagnostic code: an ASCII letter or digit, + /// or one of `._:/-`. + fn is_controlled_code_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'-') + } + + #[test] + fn the_categories_are_distinct_controlled_codes() { + let mut seen = std::collections::BTreeSet::new(); + for category in RELAY_CATEGORIES { + assert!(!category.is_empty()); + assert!(category.len() <= 64, "{category}"); + assert!(category.bytes().all(is_controlled_code_byte), "{category}"); + assert!(seen.insert(*category), "{category} is listed twice"); + } + for category in [ + RELAY_PROVIDERS_UNAVAILABLE, + RELAY_PROVIDER_FAILED, + RELAY_PROVIDER_TIMED_OUT, + RELAY_SETTINGS_REJECTED, + RELAY_KEYS_REJECTED, + ] { + assert!(seen.contains(category), "{category} is not listed"); + } } #[test] @@ -660,7 +839,7 @@ mod tests { let cancellation = Cancellation::new(); cancellation.cancel(); let relay = FakeRelay::new(&["io.github.lithe-idea.maven-build"]); - let outcome = collect_contributions(&relay, "/work/app", &cancellation, &|_| {}); + let outcome = collect_contributions(&relay, "/work/app", &cancellation, &|_, _| {}); assert!(outcome.is_err()); assert!(relay.called_providers().is_empty()); @@ -672,7 +851,7 @@ mod tests { ]) .answering("io.github.lithe-idea.gradle-build", Err(cancelled())); relay.cancel_on_invoke = Some(cancellation.clone()); - let outcome = collect_contributions(&relay, "/work/app", &cancellation, &|_| {}); + let outcome = collect_contributions(&relay, "/work/app", &cancellation, &|_, _| {}); assert!(outcome.is_err()); assert_eq!( relay.called_providers(), diff --git a/backend/tests/session.rs b/backend/tests/session.rs index a105949..1eb48ed 100644 --- a/backend/tests/session.rs +++ b/backend/tests/session.rs @@ -52,25 +52,56 @@ impl Host { .expect("write to the backend"); } - fn answer(&self, id: i64) -> Value { + /// The response to request `id`, and the `params` of every `lithe/log` + /// notification the backend sent before it. The backend writes a call's + /// terminal fact before the call's response, so the facts of a call are + /// complete once its response arrived. + fn answer_with_logs(&self, id: i64) -> (Value, Vec) { + let mut logs = Vec::new(); loop { let frame = self .frames .recv_timeout(WAIT) .expect("a frame from the backend in time"); - if frame["id"] == id && frame.get("method").is_none() { - return frame; + if frame.get("method").and_then(Value::as_str) == Some("lithe/log") { + logs.push(frame["params"].clone()); + } else if frame["id"] == id && frame.get("method").is_none() { + return (frame, logs); } } } - fn invoke(&mut self, id: i64, function: &str, params: Value) -> Value { + fn answer(&self, id: i64) -> Value { + self.answer_with_logs(id).0 + } + + fn invoke_with_logs(&mut self, id: i64, function: &str, params: Value) -> (Value, Vec) { self.request( id, "lithe/function/invoke", json!({"functionID": function, "operationID": format!("op-{id}"), "params": params}), ); - self.answer(id) + self.answer_with_logs(id) + } + + fn invoke(&mut self, id: i64, function: &str, params: Value) -> Value { + self.invoke_with_logs(id, function, params).0 + } + + /// Sends `initialize` as a host that offers plugin functions and + /// `litheLogging` 1, and returns the answer. + fn initialize(&mut self) -> Value { + self.request( + 0, + "initialize", + json!({"rootUri": "file:///tmp/state/", "capabilities": { + "experimental": { + "litheFunctions": {"versions": [1]}, + "litheLogging": {"versions": [1]} + } + }}), + ); + self.answer(0) } fn close(mut self) -> i32 { @@ -102,17 +133,14 @@ fn read_frame(reader: &mut impl BufRead) -> Option { #[test] fn the_backend_offers_the_java_commands_as_plugin_functions() { let mut host = Host::start(); - host.request( - 0, - "initialize", - json!({"rootUri": "file:///tmp/state/", "capabilities": { - "experimental": {"litheFunctions": {"versions": [1]}} - }}), - ); - let initialized = host.answer(0); + let initialized = host.initialize(); let encoded = initialized.to_string(); assert!(encoded.contains("java.className"), "{encoded}"); assert!(encoded.contains("runConfig.selectJava"), "{encoded}"); + assert_eq!( + initialized["result"]["capabilities"]["experimental"]["litheLogging"]["version"], 1, + "{initialized}" + ); let class_name = host.invoke( 1, @@ -146,3 +174,94 @@ fn the_backend_offers_the_java_commands_as_plugin_functions() { "closing stdin without exit is a failure exit" ); } + +/// The only `lithe/log` fact of `logs` with the given kind. +fn only_fact<'a>(logs: &'a [Value], kind: &str) -> &'a Value { + let facts: Vec<&Value> = logs.iter().filter(|log| log["kind"] == kind).collect(); + assert_eq!(facts.len(), 1, "expected one {kind} fact in {logs:?}"); + facts[0] +} + +#[test] +fn the_backend_reports_restricted_facts_and_suppresses_reads() { + let mut host = Host::start(); + let initialized = host.initialize(); + assert!(initialized.get("error").is_none(), "{initialized}"); + + // A successful editor read is a suppressed fact: no operationFinished. + let (class_name, class_name_logs) = host.invoke_with_logs( + 1, + "java.className", + json!({"source": "package com.example;\nclass App {}", "simpleName": "App"}), + ); + assert_eq!( + class_name["result"]["className"], "com.example.App", + "{class_name}" + ); + assert!( + !class_name_logs + .iter() + .any(|log| log["kind"] == "operationFinished"), + "a suppressed success was logged: {class_name_logs:?}" + ); + + // A failure is still logged once, with the error code and no message. + let (invalid, invalid_logs) = + host.invoke_with_logs(2, "java.className", json!({"unexpected": true})); + assert_eq!( + invalid["error"]["data"]["code"], "java.invalidRequest", + "{invalid}" + ); + let invalid_failure = only_fact(&invalid_logs, "operationFailed"); + assert_eq!(invalid_failure["operation"], "java.className"); + assert_eq!(invalid_failure["code"], "java.invalidRequest"); + assert!( + invalid_failure["durationMs"].is_u64(), + "durationMs must be an integer: {invalid_failure}" + ); + // The fact names the operationID the host sent (`op-{id}`). + assert_eq!(invalid_failure["operationId"], "op-2"); + + let (session, session_logs) = host.invoke_with_logs(3, "java.prepareLaunch", json!({})); + assert_eq!( + session["error"]["data"]["code"], "java.notSupported", + "{session}" + ); + let session_failure = only_fact(&session_logs, "operationFailed"); + assert_eq!(session_failure["operation"], "java.prepareLaunch"); + assert_eq!(session_failure["code"], "java.notSupported"); + assert_eq!(session_failure["operationId"], "op-3"); + + // No fact carries an error message, the request's source or a path. + let all_logs: Vec = [class_name_logs, invalid_logs, session_logs].concat(); + let encoded = Value::Array(all_logs).to_string(); + assert!(!encoded.contains("\"message\""), "{encoded}"); + assert!(!encoded.contains("package com.example"), "{encoded}"); + assert!(!encoded.contains("/tmp"), "{encoded}"); + + assert_eq!( + host.close(), + 1, + "closing stdin without exit is a failure exit" + ); +} + +#[test] +fn initialize_without_lithe_logging_is_refused() { + let mut host = Host::start(); + host.request( + 0, + "initialize", + json!({"rootUri": "file:///tmp/state/", "capabilities": { + "experimental": {"litheFunctions": {"versions": [1]}} + }}), + ); + let refused = host.answer(0); + assert!(refused.get("error").is_some(), "{refused}"); + assert!(refused.get("result").is_none(), "{refused}"); + assert_eq!( + host.close(), + 1, + "closing stdin without exit is a failure exit" + ); +} diff --git a/crates/java-domain/src/execution/argfile.rs b/crates/java-domain/src/execution/argfile.rs index 4ed4a00..0d6ead4 100644 --- a/crates/java-domain/src/execution/argfile.rs +++ b/crates/java-domain/src/execution/argfile.rs @@ -74,11 +74,7 @@ impl Drop for ArgumentFileLease { match fs::remove_file(&self.path) { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => {} - // TODO(host-api: API-22 log and progress): plugin log channel. - Err(error) => eprintln!( - "storage cleanup failed: stage=java-argfile-remove error_code={:?}", - error.kind() - ), + Err(_) => crate::host::diagnostics::cleanup_failed("java-argfile-remove", 1), } } } diff --git a/crates/java-domain/src/host/diagnostics.rs b/crates/java-domain/src/host/diagnostics.rs new file mode 100644 index 0000000..954ecb5 --- /dev/null +++ b/crates/java-domain/src/host/diagnostics.rs @@ -0,0 +1,222 @@ +//! The only exit through which the domain reports handled failures to the +//! plugin's unified host log. +//! +//! The domain crate does not depend on the plugin SDK, so it cannot call the +//! host logger itself. Instead the backend installs a [`Reporter`] around each +//! command it executes ([`scoped`]), and domain code reports through +//! [`degraded`] or [`cleanup_failed`]. The reporter forwards the fact to the +//! host's `degraded(operation, code, count)` log entry. +//! +//! Only a controlled category code and a count are accepted: never a message, +//! a path, an error body or any user content. The host log only takes codes +//! made of ASCII letters, digits and `._:/-` (at most 64 bytes); +//! [`is_category`] checks the same shape. +//! +//! Without a reporter (unit tests, or code that does not run on the backend's +//! command-execution thread) the fact is dropped. There is no fallback to +//! standard error: the plugin has no free-text log channel. + +use std::cell::RefCell; +use std::rc::Rc; + +/// Receives one handled-failure fact: a controlled category code and a count. +pub type Reporter = Rc; + +/// Longest category code, in bytes, that the host log accepts. +const MAX_CATEGORY_BYTES: usize = 64; + +thread_local! { + /// Reporter of the command currently executing on this thread, if any. + static REPORTER: RefCell> = const { RefCell::new(None) }; +} + +/// Puts the previous reporter back when a [`scoped`] call ends, including when +/// its body panics. +struct RestorePrevious(Option); + +impl Drop for RestorePrevious { + fn drop(&mut self) { + let previous = self.0.take(); + // The reporter being replaced is dropped after the borrow has ended, + // and a thread that is already shutting down has nothing to restore. + let _ = REPORTER.try_with(|slot| slot.replace(previous)); + } +} + +/// Runs `body` with `reporter` receiving the facts the domain reports on this +/// thread, and restores the previous reporter afterwards, also when `body` panics. +pub fn scoped(reporter: Reporter, body: impl FnOnce() -> R) -> R { + let previous = REPORTER.with(|slot| slot.replace(Some(reporter))); + let _restore = RestorePrevious(previous); + body() +} + +/// Reports a handled degradation to the current thread's reporter; does nothing without one. +/// +/// `code` must be a controlled category (see [`is_category`]) and never carry +/// a path, an error body or user content. +pub fn degraded(code: &str, count: u64) { + // The reporter is cloned out first so the cell is not borrowed while it + // runs: a reporter may itself enter a `scoped` call. + let reporter = REPORTER + .try_with(|slot| slot.borrow().clone()) + .ok() + .flatten(); + if let Some(reporter) = reporter { + reporter(code, count); + } +} + +/// Reports `count` failed removals of the cleanup `stage`. +/// The category is `storage.cleanup_failed.`. +pub fn cleanup_failed(stage: &str, count: u64) { + degraded(&format!("storage.cleanup_failed.{stage}"), count); +} + +/// Whether `code` has the shape of a log category: non-empty, at most 64 +/// bytes, and made only of ASCII letters, digits and `._:/-`. +pub fn is_category(code: &str) -> bool { + !code.is_empty() && code.len() <= MAX_CATEGORY_BYTES && code.bytes().all(is_category_byte) +} + +/// One byte a category may contain: an ASCII letter, a digit or one of `._:/-`. +fn is_category_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'-') +} + +#[cfg(test)] +mod tests { + use super::*; + use std::panic::{catch_unwind, AssertUnwindSafe}; + + type Seen = Rc>>; + + /// A reporter that records every fact it receives, and the record. + fn recorder() -> (Reporter, Seen) { + let seen: Seen = Rc::default(); + let sink = Rc::clone(&seen); + let reporter: Reporter = Rc::new(move |code: &str, count: u64| { + sink.borrow_mut().push((code.to_string(), count)); + }); + (reporter, seen) + } + + fn facts(seen: &Seen) -> Vec<(String, u64)> { + seen.borrow().clone() + } + + #[test] + fn reporting_without_a_reporter_does_nothing() { + degraded("storage.cleanup_failed.jdt-settings", 1); + cleanup_failed("jdt-settings", 1); + } + + #[test] + fn a_scope_receives_facts_and_ends_with_no_reporter() { + let (reporter, seen) = recorder(); + let result = scoped(reporter, || { + degraded("example.category", 3); + "body result" + }); + assert_eq!(result, "body result"); + assert_eq!(facts(&seen), vec![("example.category".to_string(), 3)]); + + degraded("example.after", 1); + assert_eq!(facts(&seen).len(), 1); + } + + #[test] + fn an_inner_scope_gives_the_outer_reporter_back() { + let (outer, outer_seen) = recorder(); + let (inner, inner_seen) = recorder(); + scoped(outer, || { + degraded("example.first", 1); + scoped(inner, || degraded("example.inner", 2)); + degraded("example.second", 3); + }); + assert_eq!( + facts(&outer_seen), + vec![ + ("example.first".to_string(), 1), + ("example.second".to_string(), 3) + ] + ); + assert_eq!(facts(&inner_seen), vec![("example.inner".to_string(), 2)]); + } + + #[test] + fn a_panicking_body_still_restores_the_previous_reporter() { + let (outer, outer_seen) = recorder(); + let (inner, inner_seen) = recorder(); + scoped(outer, || { + let result = catch_unwind(AssertUnwindSafe(|| { + scoped(inner, || -> u8 { panic!("reporter scope test") }) + })); + assert!(result.is_err()); + degraded("example.after_panic", 1); + }); + assert_eq!( + facts(&outer_seen), + vec![("example.after_panic".to_string(), 1)] + ); + assert!(facts(&inner_seen).is_empty()); + + // Without an outer reporter the panic leaves none behind either. + let (lone, lone_seen) = recorder(); + let result = catch_unwind(AssertUnwindSafe(|| { + scoped(lone, || -> u8 { panic!("reporter scope test") }) + })); + assert!(result.is_err()); + degraded("example.after_panic", 1); + assert!(facts(&lone_seen).is_empty()); + } + + #[test] + fn a_reporter_may_enter_a_scope_while_it_runs() { + let (inner, inner_seen) = recorder(); + let outer: Reporter = Rc::new(move |_code: &str, _count: u64| { + scoped(Rc::clone(&inner), || degraded("example.nested", 1)); + }); + scoped(outer, || degraded("example.trigger", 1)); + assert_eq!(facts(&inner_seen), vec![("example.nested".to_string(), 1)]); + } + + #[test] + fn cleanup_failures_use_the_stage_in_the_category() { + let (reporter, seen) = recorder(); + scoped(reporter, || cleanup_failed("jdt-configuration", 2)); + assert_eq!( + facts(&seen), + vec![("storage.cleanup_failed.jdt-configuration".to_string(), 2)] + ); + } + + #[test] + fn every_cleanup_stage_in_use_makes_a_valid_category() { + let (reporter, seen) = recorder(); + scoped(reporter, || { + for stage in ["jdt-configuration", "jdt-settings", "java-argfile-remove"] { + cleanup_failed(stage, 1); + } + }); + let seen = facts(&seen); + assert_eq!(seen.len(), 3); + for (code, _) in seen { + assert!(is_category(&code), "{code} is not a valid category"); + } + } + + #[test] + fn categories_have_a_controlled_shape() { + assert!(is_category("storage.cleanup_failed.jdt-settings")); + assert!(is_category("a.b_c:d/e-F9")); + assert!(is_category(&"a".repeat(64))); + + assert!(!is_category("")); + assert!(!is_category(&"a".repeat(65))); + assert!(!is_category("has space")); + assert!(!is_category("back\\slash")); + assert!(!is_category("中文类别")); + assert!(!is_category("line\nbreak")); + } +} diff --git a/crates/java-domain/src/host/mod.rs b/crates/java-domain/src/host/mod.rs index 92f5b8e..241f1ae 100644 --- a/crates/java-domain/src/host/mod.rs +++ b/crates/java-domain/src/host/mod.rs @@ -7,10 +7,15 @@ //! placeholder is marked `TODO(host-api: API-NN ...)` with its plan number //! and listed in `MIGRATION.md`. //! +//! The diagnostics log is the exception: [`diagnostics`] is the single exit +//! through which the domain reports handled failures to the plugin's unified +//! host log, as controlled category codes and counts only. +//! //! Maven and Gradle settings are not borrowed here. The build-system plugins //! compute them and hand them to this plugin through a cross-plugin function //! call (API-35), as described in `lsp::languages::jdt`. +pub mod diagnostics; pub mod git; pub mod indexing; pub mod storage; diff --git a/crates/java-domain/src/host/storage.rs b/crates/java-domain/src/host/storage.rs index 2140d34..a8f9fe5 100644 --- a/crates/java-domain/src/host/storage.rs +++ b/crates/java-domain/src/host/storage.rs @@ -2,13 +2,14 @@ //! //! Rust Core recorded failed cleanups as one `app.storage.cleanup_failed` //! diagnostics event per batch. The plugin keeps the same fixed-size summary -//! and reports it on its own log channel (standard error of the backend). +//! and reports it to the plugin's unified log through +//! [`host::diagnostics`](super::diagnostics): once per batch, carrying only the +//! stage category and the number of failed removals. /// Fixed-size cleanup summary: batch callers emit once rather than once per file. #[derive(Debug, Default)] pub struct StorageCleanup { count: i64, - first: Option, } impl StorageCleanup { @@ -18,10 +19,7 @@ impl StorageCleanup { match result { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - self.count = self.count.saturating_add(1); - self.first.get_or_insert(error.kind()); - } + Err(_) => self.count = self.count.saturating_add(1), } } @@ -42,16 +40,14 @@ impl StorageCleanup { self.count } - /// Reports the batch once, preserving the caller's normal result. - // TODO(host-api: API-22 log and progress): plugin log channel — report - // through the host's structured plugin log instead of standard error once - // it is available. + /// Reports the batch once when anything failed, preserving the caller's + /// normal result. `stage` names the cleanup and becomes part of the log + /// category (`storage.cleanup_failed.`), so it must be a fixed + /// identifier, never a path or user content. pub fn finish(self, stage: &str) { - if let Some(kind) = self.first { - eprintln!( - "storage cleanup failed: stage={stage} count={} error_code={kind:?}", - self.count - ); + let count = u64::try_from(self.count).unwrap_or(0); + if count > 0 { + crate::host::diagnostics::cleanup_failed(stage, count); } } } diff --git a/sdk.lock.json b/sdk.lock.json index e099533..a738a0b 100644 --- a/sdk.lock.json +++ b/sdk.lock.json @@ -1,9 +1,9 @@ { "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.1.0", + "version": "0.2.0", "repository": "https://github.com/Lithe-IDEA/lithe-plugin-docs", - "tag": "sdk-rust-v0.1.0", - "commit": "e689971c000c15a044df0502052fe7b2c1c6f84f", - "url": "https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/download/sdk-rust-v0.1.0/lithe-plugin-sdk-rust-0.1.0.crate", - "sha256": "8a8001ca6557863f1f231bcdbfee1db4e2ce4ab5eefd1a8ba2bf888159ebc8e9" + "tag": "sdk-rust-v0.2.0", + "commit": "940394fcd8455186ee985e6c34a319032414607f", + "url": "https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/download/sdk-rust-v0.2.0/lithe-plugin-sdk-rust-0.2.0.crate", + "sha256": "47fa8eb90e8372472108bb47ee231eb61cddc5666941ee7c024c22a9ba113143" } From f2cfe8bd69292ec4d36471a7af86680bf8f3ab63 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 5 Nov 2026 15:21:10 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(readme):=20=E5=86=99=E6=98=8E=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=A4=B1=E8=B4=A5=E6=97=B6=20stderr=20=E7=9A=84?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E4=BE=8B=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a5ad2dc..00a536b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ ## 日志 -后端把日志交给宿主的统一日志 `litheLogging` 1,不写 stderr。Rust SDK 0.2.0 要求宿主提供 `litheLogging` 1, +后端把日志交给宿主的统一日志 `litheLogging` 1,不写 stderr;唯一例外是协议开始前注册函数失败, +这时打印一行带 SDK 错误码的固定文本后退出(`backend/src/main.rs`)。Rust SDK 0.2.0 要求宿主提供 `litheLogging` 1, 不提供的 Lithe 版本会拒绝启动后端;目前只有 Lithe 最新 `preview` 提供,稳定版还没有。 | 事实 | 级别 | 来源 | 内容 | From 835498b05f21a79b42aed260deee6d9dc3868d5c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 5 Nov 2026 15:23:56 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(backend):=20=E6=B3=A8=E5=86=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=8D=E5=86=8D=E5=86=99=20stderr=EF=BC=8C=E7=94=B1?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=E4=BF=9D=E8=AF=81=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=9E=84=E5=BB=BA=E6=B3=A8=E5=86=8C=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++-- backend/src/lib.rs | 17 +++++++++++++---- backend/src/main.rs | 26 +++++++++++++------------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 00a536b..58899e1 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,9 @@ ## 日志 -后端把日志交给宿主的统一日志 `litheLogging` 1,不写 stderr;唯一例外是协议开始前注册函数失败, -这时打印一行带 SDK 错误码的固定文本后退出(`backend/src/main.rs`)。Rust SDK 0.2.0 要求宿主提供 `litheLogging` 1, +后端把日志交给宿主的统一日志 `litheLogging` 1,不写 stderr。函数 ID 都是编译期常量,注册只会因 ID 写错、重复或超出 SDK +上限而失败,单元测试 `the_backend_builds_with_every_command_registered` 在 CI 里挡住这种构建;万一发生,进程在握手前以状态 1 退出, +由宿主记录启动失败(`backend/src/main.rs`)。Rust SDK 0.2.0 要求宿主提供 `litheLogging` 1, 不提供的 Lithe 版本会拒绝启动后端;目前只有 Lithe 最新 `preview` 提供,稳定版还没有。 | 事实 | 级别 | 来源 | 内容 | diff --git a/backend/src/lib.rs b/backend/src/lib.rs index a758564..aebc4ce 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -27,8 +27,8 @@ //! `operationFinished` except for the functions outside [`KEY_OPERATIONS`], //! whose success is suppressed. Failures the domain layer handled and //! recovered from are reported as `degraded` through -//! `lithe_java_domain::host::diagnostics`. The backend never writes stderr -//! logs, and a fact carries no path, source or tool output. +//! `lithe_java_domain::host::diagnostics`. The backend never writes to +//! stderr, and a fact carries no path, source or tool output. pub mod plugin_functions; @@ -58,6 +58,11 @@ pub const KEY_OPERATIONS: &[&str] = &[ /// A function can only be suppressed once it is registered, so the successes /// of the functions outside [`KEY_OPERATIONS`] are suppressed after the /// registration loop. +/// +/// Every input is a compile-time constant, so the result is the same on each +/// run: it fails only when a function ID is invalid, repeated or over the +/// SDK's limit, which the test `the_backend_builds_with_every_command_registered` +/// rejects. pub fn backend() -> Result { let mut backend = Backend::new(BACKEND_NAME).version(env!("CARGO_PKG_VERSION")); for command in SUPPORTED_COMMANDS.iter().chain(SESSION_COMMANDS) { @@ -182,12 +187,16 @@ mod tests { .all(|code| lithe_plugin_sdk::is_plugin_error_code(code))); } + /// `main` exits without a message when [`backend`] fails, so this test is + /// what keeps that branch out of every build that passed CI. #[test] - fn every_command_is_a_valid_function_id() { + fn the_backend_builds_with_every_command_registered() { for command in SUPPORTED_COMMANDS.iter().chain(SESSION_COMMANDS) { assert!(lithe_plugin_sdk::is_function_id(command), "{command}"); } - assert!(backend().is_ok()); + if let Err(error) = backend() { + panic!("the backend does not build: {}", error.code()); + } } #[test] diff --git a/backend/src/main.rs b/backend/src/main.rs index e792f71..cb88a7a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,20 +1,20 @@ //! Entry point of the Java Support plugin backend. //! -//! Stdout carries only protocol frames. Diagnostics are sent through the -//! host's `litheLogging` channel, never printed. Stderr gets a single fixed -//! line with the SDK error code, and only when registering the functions -//! fails before the protocol starts. +//! Stdout carries only protocol frames, and the backend writes nothing to +//! stderr: diagnostics go through the host's `litheLogging` channel. +//! +//! Building the backend only fills the SDK's in-memory function table; no +//! message is sent until the host's `initialize`. It fails only when a +//! function ID constant is invalid, repeated or over the SDK's limit, which +//! is fixed at compile time and rejected by the unit test +//! `the_backend_builds_with_every_command_registered`. A build that passed +//! the tests therefore never takes the error branch; if one did, the process +//! exits with status 1 before the handshake and the host records the failed +//! start itself. fn main() { - let backend = match lithe_java_backend::backend() { - Ok(backend) => backend, - Err(error) => { - eprintln!( - "lithe-java-backend: could not register functions ({})", - error.code() - ); - std::process::exit(1); - } + let Ok(backend) = lithe_java_backend::backend() else { + std::process::exit(1); }; std::process::exit(backend.listen()); }