Skip to content

perf(lsp): 修复补全/跳转卡顿与 pom 修改后的全量重导,调优 jdtls JVM - #1573

Merged
1lck merged 5 commits into
previewfrom
perf/lsp-session-state-contention
Oct 29, 2026
Merged

1lck merged 5 commits into
previewfrom
perf/lsp-session-state-contention

Conversation

@1lck

@1lck 1lck commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

背景

Java 项目里补全、跳转时不时卡一下或"失效一会",jdtls 导入大项目期间界面掉帧。对照 openidea(同样基于 jdtls)检查后,发现我们自己的 LSP 编排层存在可修复的重复复制与主线程等待;这些代码问题尚不能解释所有真实项目中的卡顿。本 PR 包含三部分:会话热路径(1–5)、JDT -data 工作区 key(6–8)、jdtls JVM 默认参数(9)。

改动点

Rust Core(rust/lithe-core/src/lsp/interface/)

  1. 打开文档的文本改为共享,不再随状态整体复制
    • 原因:引擎在处理每个请求(补全、hover…)、每次按键同步、每条服务端消息时,都会在持锁状态下执行 state.client.clone(),而 LspClientState.open_documents 带着所有打开文档的完整文本(String)。jdtls 导入期间会连续发出成千上万条 progress / 诊断消息,每条都触发一次全文复制。同一时间 outbound_order 锁一直被持有,补全请求只能排在后面,甚至超时。
    • 改法:LspClientDocument.text 由 String 改为 Arc<str>,clone 只增加引用计数;给 serde 开启 rc 特性,JSON 仍是普通字符串,契约和 fixture 不变。
    • 文档内容只在 didOpen / didChange 时各转换一次。
  2. 每条服务端消息只解析一次 JSON
    • 原来:锁外解析 1 次;拿到 outbound_order 锁后又解析 1 次(本地工作区下内容完全相同);reducer 在状态锁内再解析 1 次。
    • 现在:本地工作区只在锁外解析 1 次;远程工作区确实改写过消息,才对改写结果再解析 1 次。
    • client_apply_server_message_for_plugin 改为直接接收解析好的 &Value。ClientApplyServerMessageRequest 只剩无状态 reducer 测试在用,标记为 #[cfg(test)]。
  3. 符号链接诊断改写不再序列化回字符串:diagnostics_addressed_to_open_document 直接返回改写后的 Value。

macOS(LanguageServerSession.swift)

  1. 事件 drain 移出主线程
    • 原因:轮询循环跑在 @MainActor 上,有请求在等结果时每 10ms、空闲时每 50ms 同步调用一次 lsp.pollEvents,包括 C ABI 调用、JSON 编解码,并等待被读线程持有的会话锁。导入期间这直接表现为打字、滚动掉帧。
    • 改法:drain 在 detached task 中执行,事件仍回到主线程处理。
  2. 保住原有的事件顺序约束
    • 原规则保持不变:有发送进行中(sendsInFlight > 0)时不开始 drain。
    • 新增规则:send 写请求前,先等待正在进行的 drain 结束。这样后台 drain 返回的事件里,不可能有新请求的回复,回复只会在操作注册之后的下一轮被取到。
    • 补全这类同步请求在主线程同一轮内完成注册,不受影响。
    • 旧轮询循环被取消后,它的 drain 结束时不会清掉新 drain 的引用;会话被释放或替换时,过期 drain 的结果直接丢弃。

JDT 工作区 key 不再包含构建文件的修改时间(Core / macOS / Windows)

  1. 构建文件只按"是否存在"计入 key
    • 原因:-data 目录的 fingerprint 包含 pom.xml / build.gradle(.kts) 的修改时间和大小。用户每改一次 pom(哪怕只加一个依赖),key 就会变化,jdtls 拿到一个全新的空 -data 目录,整个工作区重新导入、重建索引,期间补全和跳转全部不可用。
    • 依据:jdtls 自己会发现构建文件的修改,并做增量更新。Maven 由 MavenProjectImporter.needsMavenUpdate 比较 pom 时间戳和上次保存的工作区状态;Gradle 由 GradleProjectImporter.checkGradlePersistence 比较 *.gradle(.kts) 的修改时间。我们配置了 updateBuildConfiguration: "automatic"。换 -data 目录不仅多余,还把增量更新变成了全量重建。
    • 改法:JdtBuildFileObservation 只保留 path。fingerprint 改为 build=<路径>|modules=…|jdtls=…,不再出现 mtime 或 size。
  2. Gradle wrapper 版本是例外,仍会切换工作区
    • jdtls 不检查 gradle/wrapper/gradle-wrapper.properties。升级 Gradle 版本后,旧的持久化模型可能与新版本不兼容。
    • 请求新增可选字段 gradleWrapperDistribution。Windows 端读取 distributionUrl(支持 =/: 分隔、#/! 注释、反斜杠转义),存在时在 fingerprint 末尾追加 |gradle=<url>。空值返回 InvalidRequest。
    • macOS 端目前只观察 pom.xml、build.gradle、build.gradle.kts,不发送 wrapper 版本,这与改动前一致,契约里有说明。
  3. macOS 删掉读取元数据失败的错误分支:只判断文件是否存在,因此 invalidBuildFileMetadata 和对应的 i18n 词条 native.couldNotReadJavaBuildFileMetadataFor 一起删除(Localizable.strings 只有删除)。
    • 契约 shared/contracts/rust-core-api.md、fixture shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json(新增 wrapper 版本用例)和 Agent Note 2026-10-11-gradle-project-import-settings.md 同步更新。
    • ⚠️ 升级后的一次性代价:key 的格式变了,升级后第一次打开每个 Java 工程,都会重新导入、重建一次索引。之后修改 pom 不再触发全量重导。

jdtls 的 JVM 默认参数(rust/lithe-core/src/lsp/languages/jdt.rs)

  1. 加上 vscode-java 和 jdtls 上游 CI 使用的同一组默认值,wrapper 启动和直接 java 启动两条路径都加:
    • -XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90:堆上限是 2 GB,默认的 G1 会长期占住接近上限的堆;换成这组参数后,导入结束时堆会收缩,把内存还给系统。
    • -Xlog:disable:JVM 统一日志的告警默认写到 stdout,而 stdout 正是 LSP 通道。
    • -DDetectVMInstallationsJob.disabled=true:宿主已经通过 java.configuration.runtimes 传入了 JDK,jdtls 启动时不必再扫描本机 JVM。
    • 覆盖规则:插件参数里出现同一个键(-XX: 选项按名字算,所有 -Xlog 算一个键,其余按 = 之前的部分),用插件的值;插件选了任何收集器(-XX:±Use*GC)时,去掉默认的 ParallelGC。与默认值完全相同的参数视为宿主所有,重复适配的结果不变。
    • 没有照搬的参数:-Daether.dependencyCollector.impl=bf 会改变 Maven 解析策略,且没有核实 m2e 是否读取它;-Dsun.zip.disableMemoryMapping=true 在 JDK 9+ 已经无效;-Xms 保持 256m。另外确认了 -Dlog.protocol=true 在 jdtls 源码中没有任何读取方,是无效参数,删掉它也没有收益,所以保持不动。
    • 决策记录在 2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md(决策第 5 条)和 2026-10-14-declarative-plugin-settings.md。

跨平台功能矩阵

  1. shared/platform-feature-matrix/features/lsp-lifecycle.json:两端补充证据路径、验证动作(改 pom 后 -data 目录不变、wrapper 版本变化会切换目录、JVM 参数和导入后内存回落、macOS 导入期间不掉帧)和说明。两端验证状态仍是 pending。

测试

  • Rust 新增 client_core_reduction_shares_open_document_text:reduce 一条服务端消息后,文档文本与原状态仍是同一份(Arc::ptr_eq),且序列化 / 反序列化后仍是普通字符串。
  • Rust fingerprint 测试:编辑内容不改变 key,wrapper 版本会改变 key,空的 wrapper 版本会被拒绝。共享 fixture 新增 gradle-wrapper-version-selects-the-state。
  • Rust 新增 java_start_lets_plugin_jvm_options_replace_the_tuning_defaults:插件指定 G1、GCTimeRatio 和 -Xlog 时替换对应的默认值,未覆盖的默认值保留,重复适配结果稳定。两条启动路径的完整参数列表测试同步更新。
  • Swift 新增 aHeldEventDrainNeitherBlocksTheMainActorNorOvertakesARequest:fake core 把 drain 挂在后台。
    • 主线程上的测试照常继续,证明 drain 没有阻塞主线程。
    • drain 挂起期间 core 收到的函数请求数为 0,释放后回复正常送达。
  • Swift MacJdtWorkspaceStateTests:改 pom 内容和修改时间后,构建文件观察结果不变。
  • Windows 新增 editing_a_root_build_file_keeps_the_workspace_observations 和 the_gradle_wrapper_version_selects_the_workspace_state。

仓库 Skill 自审(2026-10-29)

结论:未发现需要阻塞合并的问题。 已按 lithe-code-review、develop-lithe、write-stable-tests 和 agent-notes 审查本 PR 的直接调用链、并发顺序、共享契约、测试及决策记录。

  • 范围:preview d3072abd4 ← e57008986;相较已通过完整 CI 的 51d3021bf,本轮仅同步 README/截图更新,产品实现未再修改。
  • 检查了文档文本共享所有权、消息解析与远程路径改写、macOS 发送/事件 drain 注册顺序、JDT 工作区标识、JVM 参数覆盖及两端消费者。
  • 平台矩阵及变更门禁、diff 检查通过。沿用此前完整 Core/macOS 本地验证及该实现的 Windows/macOS CI 结果;新 head 的 CI 单独确认。
  • 信息缺口:尚无真实大项目导入/补全延迟与内存的前后对比,不把代码优化等同于体验已达标。

🤖 Generated with Claude Code

真实 macOS 应用验收补充(2026-10-29)

在包含两项 PR 的 145ddc33d 预览包中实际操作 RuoYi-Vue(13db1fc,266 个 Java 源文件):打开项目自动出现运行配置并默认选中 ruoyi-admin;Java 导入完成;输入 SpringApplication.r 得到 run 方法补全,F12 能进入 Spring 依赖源码。独立 Hello.java 样例经界面完成编译、启动、输出和停止,未留下样例进程。

完整退出并启动新进程后,使用 Core 文档转换准备的已保存 build.provider 测试任务与自动检测的 8 个 Java/服务入口同时显示,无需手动识别,验证了本次重启回归修复。此项未覆盖通过 UI 保存任务。操作前后 1,394 个 bundle 文件哈希一致;基线在初次启动后采集,因此不将此结果描述为完整启动期间的哈希验证。验收后应用与 Java 子进程全部退出。

限制:未运行 Windows 虚拟机,未做前后版本性能基准,也未启动依赖数据库/Redis 的若依服务。真实界面还暴露出既有检测器把继承 Spring Boot 插件的库模块列作服务的问题;该检测器不在两项 PR 的改动中,保留后续优化项。自动识别未创建 generated.json、requirements.json、project.json;项目仍会出现原有的 local.json。不能据此声称所有 Windows 体验问题已解决。

…n actor

The engine copied the whole client state for every request, document
change, and server message while holding the session locks. That state
holds the full text of every open document, so a jdtls project import,
which publishes thousands of messages, kept completion and navigation
waiting behind the copies. Open document text is now shared (Arc<str>,
still a plain JSON string), and each server message is parsed once,
outside the state lock, instead of up to three times.

macOS drained Core's event queue on the main actor every 10 ms while
requests were pending, waiting on the same session lock. The drain now
runs off the main actor; a send waits for a drain already in flight, so
a drain still never returns a reply before its operation is registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ghfind-review ghfind-review Bot added the review: high ghfind author score; see https://ghfind.com label Oct 29, 2026
The JDT -data fingerprint no longer includes build-file mtime or size.
jdtls already detects pom and Gradle script edits itself
(MavenProjectImporter.needsMavenUpdate, GradleProjectImporter.
checkGradlePersistence), so switching to a fresh -data directory on every
edit only turned an incremental update into a full re-import. The Gradle
wrapper distribution stays part of the key because jdtls does not check it.

jdtls now launches with the GC and logging defaults vscode-java and the
upstream CI use (ParallelGC with GCTimeRatio=4 and
AdaptiveSizePolicyWeight=90, -Xlog:disable, DetectVMInstallationsJob
disabled). A plugin option with the same key replaces each default, and a
plugin collector choice replaces ParallelGC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@1lck 1lck changed the title perf(lsp): 消除会话状态整体复制与 macOS 主线程事件轮询,修复补全/跳转卡顿 perf(lsp): 修复补全/跳转卡顿与 pom 修改后的全量重导,调优 jdtls JVM Oct 29, 2026
@1lck
1lck marked this pull request as ready for review October 29, 2026 06:19
@lithe-code-review

lithe-code-review Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Lithe Review

结论: ⚠️ 有建议
范围: preview ← perf/lsp-session-state-contention · head: 8db5eba

未发现需要阻塞合并的问题。以下均为 P2 非阻塞建议。

Findings

  1. [P2] 按 Java Properties 规则读取 wrapper 的实际版本
    windows/tauri/src-tauri/src/lsp/jdt_workspace.rs:170

    • 实际影响:合法的 wrapper 配置可能读不到版本,升级 Gradle 后仍选择原来的 JDT 数据目录,失去本次新增的版本隔离。
    • 触发条件:使用空白分隔键值,或存在重复的 distributionUrl。Java Properties 支持前者,并以最后一个重复键为准;当前实现只接受 =/:,且遇到第一个值就返回。
    • 复现步骤:配置 distributionUrl https://services.gradle.org/distributions/gradle-8.10-bin.zip,再将版本改成 8.11。两次解析均返回 None,构建文件列表也相同,因此 fingerprint 不变。base 尚会通过文件元数据区分这次修改。
    • 来源:本次引入。
  2. [P2] 让事件 drain 测试明确检测 gate 超时
    macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift:1007

    • 实际影响:新增测试可能在轮询重新阻塞主线程时仍通过,无法可靠保护本次性能修复。
    • 触发条件:将生产轮询恢复为 base 的主线程同步调用。
    • 复现步骤:fake poll 先打开 pollHeld,再阻塞主线程五秒;超时返回值被忽略,随后清除 _pollIsHeld。测试恢复执行时,gate 已打开,函数调用正常完成,requestsWhilePollHeld 仍为零,现有断言全部可以成立。
    • 来源:本次引入。
  3. [P2] 同步更新工作区指纹的功能矩阵记录
    rust/lithe-core/src/lsp/languages/java_workspace.rs:239

    • 实际影响:能力记录仍要求修改 settings.gradle 内容后 fingerprint 改变,与本次行为相反;标准矩阵变更门禁也缺少所需的实质记录更新。
    • 触发条件:本次改变描述文件内容与 wrapper 版本对缓存选择的影响,但指定范围内没有修改 shared/platform-feature-matrix/features/。
    • 复现步骤:对照现有 java-gradle-project-import.json 的验收说明与新 fingerprint 实现;门禁脚本第 28–31 行也明确拒绝没有能力记录更新的平台实现变更。此处是静态判断,未运行门禁。
    • 来源:本次引入。

Scope Check

  • 关联需求:未找到可验证的关联 Issue;按 PR 描述的三部分目标审查。
  • 已完成范围:文档文本共享、消息解析复用、macOS 后台 drain、构建文件存在性指纹、Windows wrapper 版本观测及 JVM 默认参数。
  • 未完成范围:真实大型工程的性能对比、Windows 原生完整验证。
  • 上游复用:继续由 JDT LS/Buildship 拥有项目导入与增量更新,Core 负责归一化和编排,未发现新增并行项目模型。
  • 三项 Finding 均由本次变更引入,属于当前职责范围。

Suggestions

  1. 使用兼容 Java Properties 的解析器,并补充空白分隔、续行及重复键最后生效的版本切换测试。
  2. 保存并断言同步 gate 的等待结果;通过明确的请求启动同步点控制释放顺序,避免依赖两个 MainActor Task 的调度先后。
  3. 更新相关能力记录中的指纹语义和验收步骤,保留尚未完成的平台验证状态,并提供矩阵门禁结果。

Summary

文本共享与解析复用符合现有 Core 边界。主要建议集中在 wrapper 解析正确性、并发回归测试的有效性,以及行为记录同步;尚无充分证据量化实际性能收益。

Verification

  • 实际检查:核对完整 25 文件清单,阅读相关生产代码、测试、契约、fixture、i18n 源和决策笔记;追踪 Core 消息归约、Swift 发送/轮询/释放及 Windows 指纹到缓存选择的调用链。未读取生成资源正文。
  • 作者报告:Rust 测试、114 个 Swift 测试及注释、契约、i18n、测试稳定性等检查通过,运行时 bundle 只读校验通过;未独立验证这些结果。
  • 已观察 CI:提供的 head 检查仍处于 queued/in_progress,没有最终通过结果。
  • 本次未运行任何代码、测试或验证脚本,未访问网络。
  • 剩余验证缺口:Windows 原生编译与测试、真实大型 Maven/Gradle 工程导入期间的补全延迟、输入和滚动表现,以及 JVM 参数调整后的内存与停顿对比。

@1lck
1lck merged commit 0888722 into preview Oct 29, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review: high ghfind author score; see https://ghfind.com

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant