fix(lsp): synchronize target documents before queries (#11443)

* fix(lsp): synchronize target documents before queries

* fix(lsp): address document synchronization review findings

* fix(lsp): address second-round document synchronization review findings

Evict an un-synchronizable document where the throw is raised, so one
deleted or unsupported file stops being fatal for the rest of the
connection, while workspace synchronization failures still escape the
ordinary pull-request catch instead of returning partial success.

Certify a prepare result that points at another file by observing that
file inside the synchronous signing loop, where no await separates the
response from the observation. The documented prepare-then-traverse
workflow no longer fails closed on a fresh cross-file item, and it costs
no notification, no settle delay and no second round trip. Nested items
whose own file drifted stay unsigned without dropping healthy siblings,
non-file items report non-retryable guidance, and tokens bind a text
digest plus delivered version rather than the whole document.

An unreadable warmup probe no longer cancels workspace symbol search, and
the manager only blames the synchronization callback when the server's
capabilities really cannot accept a notification.

Both design docs now define each mutation gate inline instead of citing
identifiers that resolve to nothing in the tree.

* fix(lsp): resolve R3 review findings and fix replayUris memory leak

- Fix memory leak: add replayUris.clear() in stop()
- Resolve all 17 R3 review findings (3 Critical + 14 Suggestion)
- Fix TypeScript crash restart document replay
- Improve error messages with specific file URIs
- Add pin tests for all fixes

* fix(lsp): preserve durable replay obligations across connection change and reload

Park wiped URIs into replayUris before clearing openedDocuments on a
connection change, fold the durable set into the reload snapshot and the
workspace-sweep tracked set, and evict a never-delivered unreadable URI so
one permanently unreadable file cannot wedge later sweeps.

* fix(core): preserve LSP replay and hierarchy provenance

---------

Co-authored-by: Shaojin Wen <[email protected]>
This commit is contained in:
Yan ShenandShaojin Wen authored and GitHub committed 2026-09-12 23:53:08 +00:00
1 parent b4d61e3a0b
commit 4f56430138
17 files changed
+3975 -236

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
# Query-time native LSP document synchronization (PR1)
[English](lsp-disk-document-sync.md) | [简体中文](lsp-disk-document-sync.zh-CN.md)
Native LSP queries previously opened each URI once and never refreshed its text.
The service owns a per-server, per-connection map of delivered disk text and
version. Before location and document queries, it reads the target file and
sends only the required notification. Versions start at 1 on each connection
and increase on delivery of changes, including forced warmup. A new connection
discards its predecessor's state; configuration reload replays current disk text
for restarted servers and retains unchanged servers' state.
Saved changes from edit/write tools, hooks, shell commands, or manual editors
are covered when that target is next queried, regardless of the writer. Unsaved
buffers and edit-time feedback are not covered. #3170's edit-time `didSave` work
is complementary; #3029/#3034 diagnostics work and #11418 documentation updates
remain separate.
Initialization retains only `textDocumentSync`. Numeric Full/Incremental imply
open/close support; options honor `openClose` and `change` independently. Full
sends the new text. Incremental replaces the entire previous document range,
using UTF-16 code units and treating CRLF, LF, and CR as line breaks. None or an
absent capability does not authorize change notifications. Only notification sends that do not throw record a snapshot (not a server ACK). A server without `openClose` receives
neither `didOpen` nor orphan `didChange`; it retains ownership of disk loading
and remains queryable after edits. If a client-opened file changes without change
support, synchronization raises an unsupported-sync error and skips the request.
Read and unsupported-change failures close the opened document, release its text,
and fail the observing call. Unsupported changes park the URI for a later reopen
with current disk text. A failed close retains a pending-close error, version and
read-failure count; subsequent synchronization (including workspace diagnostics) retries the
close before any reopen. A successful close allows later calls to recover. Versions
remain monotonic across same-connection close/reopen, including identical text.
Only connection replacement resets versions. Ordinary thrown change sends retain
the prior snapshot and retryable version. No send return is an acknowledgement.
TypeScript warmup delegates delivery to the same service helper. Forced warmup
sends a capability-supported `didChange` even for unchanged text, advancing its
version without duplicate `didOpen`. Already-current supported warmup still settles without warning. If no notification
is supported (including forced unchanged warmup without change support), the
manager warns with the server and capability and records the warmup attempt to
avoid repeated discovery scans. Actual callback/read/send failures are caught
and leave the handle retryable, including after a failed forced attempt. A delayed
warmup cannot mark a replacement connection warm. Normal unchanged queries send nothing. Only
new opens trigger document-query delay/retry; changes do not. Workspace symbol
warmup distinguishes finding a usable file from sending an open: a disk-reading
server still gets indexing delay and empty-result retry when a file is available,
consistently across calls. With no warmup file, both are skipped. Reload replay
settles only when notifications were delivered.
Call hierarchy items carry an optional client `documentRevision` field, echoed
unchanged through the tool's JSON and native client. Native incoming/outgoing
calls require valid provenance rather than applying stale offsets to fresh text.
A small HMAC over the actual normalized LSP item parameters, server name, text digest,
and delivered version binds the item to a concrete connection. Signing recursively
sorts JSON object keys while preserving array order, so equivalent tool JSON is
accepted regardless of key order while changed values remain invalid. A `WeakMap`
holds one random secret per connection, not an issued-item registry. The client
field is never forwarded to the language server. Captured pre-request snapshots
are checked against disk, delivered version, active handle and connection before
signing results; for these pre-request snapshots, sibling synchronization or
replacement cannot certify an old response as new. Incoming/outgoing calls validate before and after warmup and
again after the request. Stale, missing, modified or unknown provenance rejects
with an actionable “prepare call hierarchy again” tool failure, never “no calls”.
Cross-file prepare results are certified only when their file already has a
pre-request snapshot that remains fresh. Otherwise they stay unsigned, even when
readable after the response: a post-response read cannot establish which text the
server used. Traversal of an unsigned item names its own URI and requires prepare
at a current location there. Signing sends no notification and issues no
supplemental request. The query target is
still snapshotted before its request and bound to the original handle/connection
across awaits.
Returned offsets are never used for automatic prepare, nor are names guessed.
Nested incoming/outgoing items get provenance only when their file was observed
before the request; unobserved nested files remain displayable, but need explicit
prepare at a current location before traversal. Unrelated drifted or unreadable nested files also remain unsigned without dropping
healthy siblings. Root freshness and connection changes still reject the whole
request, even for an empty result or request error. Non-file URIs remain unsigned
and report “cannot be traversed; prepare at a file location instead”, not a
retryable stale error. Ordinary non-file requests pass through unchanged.
## Boundaries
- Workspace symbols remain warmup-only. Optional warmup failures log and do not
cancel symbol search. Successful disk-reader discovery is connection-local;
readability is rechecked without retaining delivered text, removed candidates
trigger rediscovery and missing candidates are not negatively cached. Workspace diagnostics additionally
synchronize already tracked documents for each queried server, inside the
result-limited loop (default limit 100). They neither discover more documents
nor synchronize servers skipped after the limit. Tracked means delivered on this
connection or parked by an earlier connection change or recoverable synchronization failure; the reload snapshot and the
sweep use the same union, and the reload's clear consumes the parked set.
Reconnection resync opens every
tracked URI, then settles once on new opens only (no delay for didChange).
Replacement during that await rejects rather than querying an unsynchronized
connection. A fixed delay does not prove server analysis has completed. Pending closes are retried without rediscovering documents. The tool's optional top-symbol
reference lookup is document-targeted and also synchronizes.
- No file watchers, edit-time feedback, IDE buffers, installation, workspace-wide
dependency freshness, or diagnostic push/pull redesign. Reads observe disk
snapshots, not an atomic transaction with concurrent external writers. For
disk-reading servers, unchanged text has no client-delivered version; external
edits that return to identical text between observations cannot be detected.
- Document and workspace diagnostics reject synchronization failures even after
an earlier server returned diagnostics. Workspace synchronization failures
(unreadable/deleted tracked files, thrown sends, or unsupported changes) escape
the ordinary pull-request catch; no empty or partial success is returned. A
tracked URI remains parked after its first failed read, including ENOENT. Its
second consecutive failed read drops only that URI from the parked set; both
observing diagnostic calls reject. The shared synchronization helper applies
this bound to ordinary queries, warmup and sweeps, counting
actual reads rather than calls. A successful read resets the count, and a new
connection resets counts and versions. An untracked, unreadable first query
does not create a replay obligation. After eviction, later sweeps can proceed;
a successful document-targeted query is needed to track the restored file again.
Eviction retains the same-connection version and any pending close. Unsupported
changes and thrown sends do not consume read retries. Configuration-reload
replay still consumes the parked set before delivery; retaining failed reload
deliveries remains deferred. The manager catches internal TypeScript
warmup errors; failure of a different warmup file does not prevent querying a
synchronized target. Propagated failures reach the tool's existing failure
message rather than claiming a clean or complete result. Successful empty
diagnostics still display as clean. Existing request/pull catches and public
query catches other than hierarchy provenance handling are unchanged and can
return empty arrays or null; these are **not evidence of clean diagnostics**.
Broader error result design remains PR2.
- Notification delivery is not acknowledged by the transport. This change does
not redesign asynchronous writes/closed connections.
## Costs
Synchronizable target queries read and compare complete disk text even when size
and mtime are unchanged. Non-notifiable, untracked ordinary targets skip that
read; hierarchy provenance still observes disk. Retained snapshots cost memory proportional to delivered
documents per server connection until tracking is cleared. Hierarchy requests
reuse immutable snapshot text digests and capture references once per traversal.
Each pre-warmup/post-warmup/post-response/error checkpoint reobserves disk;
per-result batches share only synchronous per-URI observations, never across awaits.
HMACs remain per item and include canonical parameters, text digest and version; connection
secrets are weakly held, with no growth per issued item. Unchanged normal queries
send no notification. Both Full and Incremental send all new text; Incremental
also scans old text for its range. A minimal diff is an upgrade only if large-file
measurements justify it. The 20-item same-URI traversal test requires three target reads (one at each
happy-path checkpoint), not one per item; error paths add a fresh observation.
After prepare, 20-item traversal constructs 23 small HMACs (3 root validations
plus 20 items), and hashes text zero times for the retained snapshot, once for
an untracked disk-reader snapshot. These are test assertions, not real-server
latency guarantees.
This is an observation-bound guarantee, not atomicity against external writers.
## Verification and filenames
Focused tests cover all ten document query routes, unchanged/changed text,
per-URI versions (both B then A edited on one connection), numeric/options sync
kinds, UTF-16/CRLF replacement, connection replacement, scoped configuration
replay, stale queries resuming after reload, warmup delivery and recovery,
TSX language precedence, read/send failures including a failed second-document
open, exact request URI/position, same-size edits with restored mtime, and a
separate process saving the target. Hierarchy tests exercise JSON tool roundtrip,
nested items, line-shifting edits, sibling queries, disk-reading servers and
in-flight response races. Workspace diagnostic ordering, result-limit scoping,
and symbol retries are pinned. Actual-client/tool tests reject deleted tracked
files, thrown sends, and unsupported workspace changes, including after an
earlier server returned results, while preserving ordinary pull-request catches. Initialization tests exercise capability production through startup.
Mutation checks must kill each named mutant, all in `native-lsp-service.ts`
unless another file is named, with the listed test going red: R1-5,
`ensureDocumentSynchronized` returns a constant `true` instead of the open flag
(`does not delay or retry an empty query after didChange`); R1-6, the manager
warmup catch in `lsp-server-manager.ts` rethrows instead of returning
(`contains TypeScript warmup callback failures`); R1-8, the snapshot write moves
before the `didOpen` send (`retries a failed second-document didOpen at version
1`); R1-10, hover sends the end position instead of the start (`sends the
requested hover URI and start position`); R1-11, synchronization reads the first
tracked document instead of the requested URI (`keeps text and versions
independent for two documents`); R1-12, the delegated language ID loses
precedence over the extension-derived one (`preserves the extension-derived
language ID in a TSX-only warmup`); R1-13, reload also clears the untouched
server's tracking (`retains the unchanged server snapshot when only its sibling
reloads`); R1-15, forced warmup passes `false` instead of the force flag
(`forces unchanged TypeScript warmup with a monotonic didChange before retry`).
R1-14 is the negative control: raising `DEFAULT_LSP_WARMUP_DELAY_MS` to 300 in
`constants.ts` must leave `preserves replayed snapshots` green.
The touched service and manager and their collocated unit tests were renamed to
kebab-case per AGENTS.md. Their barrel exports, native client type imports,
integration test, and direct E2E harness imports were updated. Public class names
and root barrel export names are unchanged; import-only legacy files were not
renamed. Old PascalCase subpath imports no longer resolve. The public manager
warmup method now requires a synchronization callback returning delivery status
(`boolean`) and returns `Promise<void>` instead of `Promise<string | undefined>`.
The optional `documentRevision` field preserves item shape compatibility, but
native traversal of legacy items without provenance now explicitly fails and
requires prepare again. Repository callers are migrated; external compatibility
policy remains subject to maintainer confirmation.
+132
View File
@@ -0,0 +1,132 @@
# 原生 LSP 的查询时文档同步(PR1)
[English](lsp-disk-document-sync.md) | [简体中文](lsp-disk-document-sync.zh-CN.md)
原生 LSP 查询此前只打开每个 URI 一次,之后不再刷新文本。现在服务按服务器和连接
保存已交付的磁盘文本及版本。在位置和文档查询之前,服务读取目标文件,只发送所需
通知。每个连接的版本从 1 开始,在交付变更时递增,也包括强制预热。新连接丢弃旧连接
的状态;配置重载为重启的服务器重放当前磁盘文本,同时保留未变更服务器的状态。
无论写入者是 edit/write 工具、钩子、shell 命令还是手动编辑器,下次查询目标时都会
覆盖已保存的变更。不覆盖未保存缓冲区和编辑时反馈。#3170 的编辑时 `didSave` 工作
与此互补;#3029/#3034 的诊断工作及 #11418 的文档更新仍独立进行。
初始化仅保留 `textDocumentSync`。数值 Full/Incremental 隐含打开/关闭支持;选项
对象分别遵守 `openClose` 和 `change`。Full 发送新文本。Incremental 替换整个旧文档
范围,使用 UTF-16 代码单元,并将 CRLF、LF 和 CR 视为换行。None 或缺失能力不授权
发送变更通知。只有不抛错的通知发送才记录快照(不代表服务端 ACK)。不支持 `openClose` 的服务器既不接收
`didOpen`,也不接收无对应打开的 `didChange`;它自行从磁盘加载,编辑后仍可查询。
如果客户端已打开的文件发生变更而服务器不支持变更通知,同步抛出不支持同步的错误
并跳过请求。读取或不支持变更的失败会关闭已打开文档、释放全文,并让当前调用失败。
不支持变更时会停放 URI,供后续使用当前磁盘文本重新打开。关闭失败保留待关闭错误、
版本和读取失败计数;后续同步(包括工作区诊断)在重开前重试关闭。关闭成功后
后续调用可以恢复。同连接关闭/重开即使文本相同,版本也保持递增;仅连接替换重置
版本。普通变更发送抛错保留旧快照和可重试版本。发送返回绝不代表服务端确认。
TypeScript 预热将交付委托给同一个服务辅助方法。强制预热即使文本未变,也发送能力
允许的 `didChange`,递增版本而不重复 `didOpen`。已同步且支持通知的预热仍等待稳定,不警告。能力确实不支持通知时(包括强制预热
未变文本但不支持 change),管理器记录包含
服务器和能力的警告,并记录已尝试预热,避免重复扫描。实际的回调、读取或发送失败
会被捕获,保持可重试,包括强制尝试失败后。延迟的预热不会将替换连接标记为
已预热。普通的未变更查询不发送通知。只有新打开才触发
文档查询的等待和重试,变更不会。工作区符号预热区分找到可用文件和发送打开通知:
存在文件时,自行读盘的服务器在每次调用中也会获得索引等待和空结果重试。没有预热
文件时,两者都跳过。重载重放仅在交付了通知时等待稳定。
调用层次项包含可选的客户端 `documentRevision` 字段,经工具 JSON 和原生客户端
原样回传。原生 incoming/outgoing 调用要求有效来源信息,不会将旧偏移应用于新文本。
一个小型 HMAC 使用实际规范化的 LSP 项参数、服务器名称、文本摘要及已交付版本,将项
绑定到具体连接。签名递归排序 JSON 对象键,但保留数组顺序,因此等值工具 JSON 的
键顺序变化不会被拒绝,实际值发生变化仍然无效。`WeakMap` 为每个连接保存一个随机
密钥,不维护已签发项的注册表。
客户端字段绝不转发给语言服务器。签发结果前,对请求前捕获的快照检查磁盘文本、
已交付版本、活动句柄和连接;对于这些请求前快照,其他查询的同步或连接替换不能将旧响应认证为新响应。
incoming/outgoing 调用在预热前后及请求后进行验证。陈旧、缺失、修改过或未知的来源
信息会拒绝操作,工具明确提示 “prepare call hierarchy again”,绝不显示“没有调用”。
跨文件 prepare 结果仅在其文件已有请求前快照且仍新鲜时认证。其他结果保持无签名,
即使响应后文件可读:响应后的读取无法证明服务器使用了哪份文本。遍历无签名项时,
错误指出该项自身的 URI,要求在该文件的当前位置重新 prepare。签名不发送通知,也不发起额外请求。
查询目标仍在其请求前捕获快照,并跨 await 绑定到原始句柄和连接。绝不使用返回的旧偏移自动 prepare,也不按名称猜测。嵌套的 incoming/outgoing 项仅在
请求前已观察其文件时获得来源信息;未观察文件的嵌套项仍可显示,但继续遍历之前必须
在当前位置显式 prepare。无关嵌套文件漂移或不可读也仅保持无签名,不丢弃健康兄弟项。根目标新鲜度或连接
变化仍拒绝整个请求,包括空响应和请求错误。非文件 URI 保持无签名,并提示
“cannot be traversed; prepare at a file location instead”,不提示可重试的陈旧错误。
普通非文件请求保持透传。
## 边界
- 工作区符号仍只进行预热。可选预热失败记录日志但不取消搜索。自行读盘服务器的
成功发现结果按连接缓存;重新检查可读性而不保留交付文本,候选删除则重新发现,
无候选时不负缓存。工作区诊断还会在带结果数量限制的循环内,为每个被查询
的服务器同步已跟踪文档(默认限制 100)。不会发现更多文档,也不会同步达到限制
后被跳过的服务器。已跟踪指在本连接上已交付、或由更早的连接变更或可恢复同步失败停放;重载快照与
sweep 使用同一并集,重载的清空会消费该停放集。重连同步打开全部已跟踪 URI 后,
仅为新打开统一等待一次;
didChange 不等待。该 await 期间连接替换会拒绝,而非查询未同步的新连接。
固定等待不证明服务器分析已完成。待关闭文档会重试关闭,不发现额外文档。工具可选的首个符号引用查询以文档为目标,同样会同步。
- 不增加文件监视器、编辑时反馈、IDE 缓冲区、安装、工作区级依赖新鲜度保证或诊断
推送/拉取重构。读取观察的是磁盘快照,不是与并发外部写入者的原子事务。自行读盘
的服务器在文本未变时没有客户端交付版本;两次观察之间先变更再恢复相同文本的
外部编辑无法被检测。
- 文档和工作区诊断在同步失败时拒绝,即使此前已有服务器返回诊断。工作区同步失败
(已跟踪文件不可读或被删除、发送抛错、不支持变更)不会被普通拉取请求的捕获逻辑
吞掉,也不会返回空结果或部分成功。已跟踪 URI 首次读取失败后仍停放,包括 ENOENT;
连续第二次读取失败仅将该 URI 从停放集移出,两次观察到失败的诊断调用均拒绝。
共享同步辅助方法将此上限应用于普通查询、预热和 sweep,按实际读取次数
而非调用次数计数。读取成功清零计数,新连接重置计数和版本。从未跟踪的文件首次
查询不可读时,不创建重放义务。逐出后后续 sweep 可以继续;文件恢复后需通过成功的
文档定向查询重新纳入跟踪。逐出保留同连接版本及待关闭状态。不支持变更和发送抛错
不消耗读取重试次数。配置重载重放仍在交付前消费停放集;保留重载失败交付的义务
继续延后。管理器捕获内部的
TypeScript 预热错误;其他预热文件失败不会阻止查询已同步目标。传播的失败使用
工具已有的失败提示,而不是声称结果干净或完整。成功的空诊断仍显示为干净。除
调用层次来源处理外,已有请求/拉取及公共查询的捕获逻辑不变,可能返回空数组或
null;这些**不能证明诊断干净**。更广泛的错误结果设计仍属于 PR2。
- 传输不确认通知交付。本变更不重构异步写入或已关闭连接的处理。
## 成本
可同步目标查询读取并比较完整磁盘文本,即使大小和 mtime 未变。不支持通知且
未跟踪的普通目标跳过读盘,但层次来源验证仍观察磁盘。保留的快照内存
与每个服务器连接已交付的文档成正比,直到清除跟踪。层次遍历仅捕获一次快照引用,并复用不可变快照的文本摘要。预热前、预热后、
响应后及错误路径的每个检查点都重新读盘;结果批次仅同步共享每 URI 的观察,
绝不跨 await 缓存。每项独立 HMAC 包含规范化参数、文本摘要及版本;连接密钥采用弱引用,不随签发项数量增长。普通未变更
查询不发送通知。Full 和 Incremental 都发送全部新文本;Incremental 还会扫描旧
文本以计算范围。只有大文件测量证明必要时才升级到最小差异。20 项同 URI 遍历测试要求目标读取三次(正常路径每个检查点一次),而非每项
一次;错误路径另加新观察。prepare 后,20 项遍历创建 23 个小 HMAC(3 次根验证
加 20 项),保留快照的全文哈希次数为零,未跟踪的自行读盘快照为一次。这些是
测试断言,不是真实服务器延迟保证。这是基于观察的保证,不是与外部写入者的原子事务。
## 验证与文件命名
聚焦测试覆盖全部十条文档查询路径、文本未变/变更、每 URI 版本(同一连接先编辑 B
再编辑 A)、数值/选项同步能力、UTF-16/CRLF 替换、连接替换、限定范围的配置重放、
重载后恢复的旧查询、预热交付与恢复、TSX 语言优先级、读取/发送失败(包括第二个
文档打开失败)、精确请求 URI/位置、恢复 mtime 的同大小编辑,以及独立进程保存
目标。调用层次测试覆盖工具 JSON 往返、嵌套项、移动行的编辑、其他查询、自行读盘
服务器及在途响应竞态。固定工作区诊断顺序、结果限制范围及符号重试行为。实际客户端
和工具测试验证:已跟踪文件被删除、发送抛错或工作区变更不受支持时拒绝操作,包括
此前已有服务器返回结果的情况,同时保留普通拉取请求的捕获逻辑。初始化测试通过启动路径
验证能力的产生。变异检查必须杀死下列每个具名变异体(除注明外均位于
`native-lsp-service.ts`),并使所列测试变红:R1-5,`ensureDocumentSynchronized`
返回恒为 `true` 而非打开标志(`does not delay or retry an empty query after
didChange`);R1-6,`lsp-server-manager.ts` 的预热 catch 重抛而非返回
(`contains TypeScript warmup callback failures`);R1-8,快照写入移到 `didOpen`
发送之前(`retries a failed second-document didOpen at version 1`);R1-10,hover
发送结束位置而非起始位置(`sends the requested hover URI and start position`);
R1-11,同步读取首个被跟踪文档而非请求的 URI(`keeps text and versions independent
for two documents`);R1-12,委托的语言 ID 失去相对扩展名推导值的优先级
(`preserves the extension-derived language ID in a TSX-only warmup`);R1-13,
重载同时清除未变更服务器的跟踪(`retains the unchanged server snapshot when only
its sibling reloads`);R1-15,强制预热传入 `false` 而非强制标志(`forces
unchanged TypeScript warmup with a monotonic didChange before retry`)。R1-14 是
阴性对照:将 `constants.ts` 中的 `DEFAULT_LSP_WARMUP_DELAY_MS` 提高到 300 后,
`preserves replayed snapshots` 仍须通过。
按照 AGENTS.md,涉及的服务、管理器及其相邻单元测试此前已重命名为 kebab-case。
其聚合导出、原生客户端类型导入、集成测试和直接 E2E 测试框架导入已更新。公共类名
和根聚合导出名不变;仅涉及导入的旧文件未重命名。旧 PascalCase 子路径导入不再
解析。公共管理器预热方法现在要求返回交付状态(`boolean`)的同步回调,并返回
`Promise<void>`,而非 `Promise<string | undefined>`。可选 `documentRevision`
字段保留项结构的兼容性,但原生遍历缺少来源信息的旧项现在会明确失败,要求重新
prepare。仓库内调用方已迁移;外部兼容性策略仍需维护者确认。
-2
View File
@@ -203,7 +203,6 @@ export const legacyFilenames = [
'LspConfigLoader',
'LspConnectionFactory',
'LspResponseNormalizer',
'LspServerManager',
'markdownUtilities',
'matcherGrouping',
'mcpCommand',
@@ -237,7 +236,6 @@ export const legacyFilenames = [
'monitorRegistry',
'mustTranslateKeys',
'NativeLspClient',
'NativeLspService',
'nextSpeakerChecker',
'nonInteractiveCli',
'nonInteractiveCliCommands',
+3 -3
View File
@@ -553,9 +553,9 @@ export * from './lsp/configHash.js';
export * from './lsp/LspConfigLoader.js';
export * from './lsp/LspConnectionFactory.js';
export * from './lsp/LspResponseNormalizer.js';
export * from './lsp/LspServerManager.js';
export * from './lsp/lsp-server-manager.js';
export * from './lsp/NativeLspClient.js';
export * from './lsp/NativeLspService.js';
export * from './lsp/native-lsp-service.js';
export * from './lsp/types.js';
// ============================================================================
@@ -798,7 +798,7 @@ export {
resolveStopHookBlockingCap,
formatStopHookBlockingCapWarning,
} from './hooks/stopHookCap.js';
export { type StopFailureErrorType } from './hooks/types.js';
export type { StopFailureErrorType } from './hooks/types.js';
export { buildContextUsage } from './hooks/context-usage.js';
export {
USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG,
@@ -6,7 +6,7 @@
import { describe, expect, it, vi } from 'vitest';
import { NativeLspClient } from './NativeLspClient.js';
import type { NativeLspService } from './NativeLspService.js';
import type { NativeLspService } from './native-lsp-service.js';
import type { LspServerHandle } from './types.js';
const createHandle = (overrides: Partial<LspServerHandle>): LspServerHandle =>
+1 -1
View File
@@ -33,7 +33,7 @@ import type {
LspServiceReinitializeResult,
} from './types.js';
import type { NativeLspService } from './NativeLspService.js';
import type { NativeLspService } from './native-lsp-service.js';
function getErrorMessage(error: unknown): string | undefined {
if (error === undefined || error === null) {
@@ -23,7 +23,7 @@
* Usage: npx tsx packages/core/src/lsp/__e2e__/lsp-e2e-test.ts
*/
import { NativeLspService } from '../NativeLspService.js';
import { NativeLspService } from '../native-lsp-service.js';
import { EventEmitter } from 'events';
import { pathToFileURL } from 'url';
import * as path from 'path';
+1 -15
View File
@@ -6,21 +6,7 @@
import { createHash } from 'node:crypto';
import type { LspServerConfig } from './types.js';
function sortJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sortJsonValue);
}
if (value && typeof value === 'object') {
// Object.create(null) avoids prototype pollution from __proto__ keys
const sorted = Object.create(null) as Record<string, unknown>;
for (const key of Object.keys(value).sort()) {
sorted[key] = sortJsonValue((value as Record<string, unknown>)[key]);
}
return sorted;
}
return value;
}
import { sortJsonValue } from './sort-json-value.js';
export function lspServerConfigHash(config: LspServerConfig): string {
const hashInput = {
@@ -11,12 +11,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config as CoreConfig } from '../config/config.js';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
import { LspServerManager } from './LspServerManager.js';
import { LspServerManager } from './lsp-server-manager.js';
import { LspConnectionFactory } from './LspConnectionFactory.js';
import type {
LspServerHandle,
LspConnectionInterface,
LspConnectionResult,
LspServerConfig,
LspTextDocumentSync,
} from './types.js';
const debugLoggerMock = vi.hoisted(() => ({
@@ -107,6 +109,159 @@ describe('LspServerManager', () => {
vi.useRealTimers();
});
it('contains TypeScript warmup callback failures without marking the handle warm', async () => {
const manager = createTrustedManager();
const warmupFile = path.resolve('main.ts');
// SAFETY: Stub discovery only; the real warmup callback and catch execute.
vi.spyOn(
manager as unknown as { findFirstTypescriptFile(): string | undefined },
'findFirstTypescriptFile',
).mockReturnValue(warmupFile);
const handle: LspServerHandle = {
config: { ...serverConfig, name: 'typescript' },
status: 'READY',
connection: {
request: vi.fn(),
send: vi.fn(),
} as unknown as LspConnectionInterface,
};
const synchronize = vi.fn(() => {
throw new Error('sync failed');
});
await expect(
manager.warmupTypescriptServer(handle, synchronize),
).resolves.toBeUndefined();
expect(synchronize).toHaveBeenCalledExactlyOnceWith(
pathToFileURL(warmupFile).toString(),
'typescript',
);
expect(handle.warmedUp).toBeFalsy();
expect(debugLoggerMock.warn).toHaveBeenCalledWith(
'TypeScript server warm-up failed:',
expect.objectContaining({ message: 'sync failed' }),
);
});
it('does not latch a replacement connection after an obsolete warmup delay', async () => {
vi.useFakeTimers();
const manager = createTrustedManager();
vi.spyOn(
manager as unknown as { findFirstTypescriptFile(): string | undefined },
'findFirstTypescriptFile',
).mockReturnValue(path.resolve('main.ts'));
const handle: LspServerHandle = {
config: { ...serverConfig, name: 'typescript' },
status: 'READY',
textDocumentSync: 1,
connection: createMockConnection(),
};
const pending = manager.warmupTypescriptServer(handle, () => true);
handle.connection = createMockConnection();
await vi.runAllTimersAsync();
await pending;
expect(handle.warmedUp).not.toBe(true);
});
it('keeps an established latch when a forced warmup finds no TypeScript file', async () => {
const manager = createTrustedManager();
const discovery = vi
.spyOn(
manager as unknown as {
findFirstTypescriptFile(): string | undefined;
},
'findFirstTypescriptFile',
)
.mockReturnValue(undefined);
const handle: LspServerHandle = {
config: { ...serverConfig, name: 'typescript' },
status: 'READY',
warmedUp: true,
connection: createMockConnection(),
};
const synchronize = vi.fn(() => true);
// A forced attempt that never reaches delivery (no TypeScript file found)
// must not destroy the established latch: the unlock sits below the guard.
await manager.warmupTypescriptServer(handle, synchronize, true);
expect(handle.warmedUp).toBe(true);
expect(synchronize).not.toHaveBeenCalled();
// A later ordinary query stays short-circuited by the intact latch, so the
// unbounded per-query discovery glob is not re-run.
discovery.mockClear();
await manager.warmupTypescriptServer(handle, synchronize);
expect(discovery).not.toHaveBeenCalled();
expect(handle.warmedUp).toBe(true);
});
it('latches a warmup that delivered no notification when textDocumentSync is absent', async () => {
vi.useFakeTimers();
const manager = createTrustedManager();
vi.spyOn(
manager as unknown as { findFirstTypescriptFile(): string | undefined },
'findFirstTypescriptFile',
).mockReturnValue(path.resolve('main.ts'));
const handle: LspServerHandle = {
config: { ...serverConfig, name: 'typescript' },
status: 'READY',
connection: createMockConnection(),
};
// textDocumentSync omitted: resolveTextDocumentSync must yield openClose:false
// so a no-notification delivery latches warm instead of awaiting the delay.
const pending = manager.warmupTypescriptServer(handle, () => false);
// The no-notification branch must return before awaiting the warmup delay:
// flushing timers first would mask a hoisted setTimeout.
expect(vi.getTimerCount()).toBe(0);
await vi.runAllTimersAsync();
await pending;
expect(handle.warmedUp).toBe(true);
expect(debugLoggerMock.warn).toHaveBeenCalledWith(
expect.stringContaining('delivered no notification'),
);
});
it.each<LspTextDocumentSync | undefined>([
0,
1,
2,
undefined,
{ openClose: true, change: 2 },
{ openClose: false, change: 0 },
])(
'retains initialize textDocumentSync %j on the ready handle',
async (textDocumentSync) => {
const manager = createTrustedManager();
const connection = createMockConnection({
initialize: vi.fn(async () => ({ capabilities: { textDocumentSync } })),
});
const createSocket = vi
.spyOn(LspConnectionFactory, 'createSocketConnection')
.mockResolvedValue({ connection } as Awaited<
ReturnType<typeof LspConnectionFactory.createSocketConnection>
>);
try {
manager.setServerConfigs([
{
...serverConfig,
command: undefined,
transport: 'socket',
socket: { port: 1234 },
},
]);
await manager.startAll();
expect(manager.getHandles().get('clangd')).toMatchObject({
status: 'READY',
textDocumentSync,
});
expect(connection.initialize).toHaveBeenCalledOnce();
expect(connection.send).toHaveBeenCalledWith(
expect.objectContaining({ method: 'initialized' }),
);
} finally {
await manager.stopAll();
createSocket.mockRestore();
}
},
);
describe('reconcileServerConfigs', () => {
it('starts added servers', async () => {
const { manager, privateView } = createReconcileManager();
@@ -8,7 +8,6 @@ import type { Config as CoreConfig } from '../config/config.js';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
import { spawn, type ChildProcess } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { globSync } from 'glob';
@@ -22,6 +21,7 @@ import {
DEFAULT_LSP_STARTUP_TIMEOUT_MS,
DEFAULT_LSP_WARMUP_DELAY_MS,
} from './constants.js';
import { resolveTextDocumentSync } from './types.js';
import type {
LspConnectionResult,
LspProcessDiagnostics,
@@ -30,6 +30,7 @@ import type {
LspServerHandle,
LspServerStatus,
LspSocketOptions,
LspTextDocumentSync,
} from './types.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { lspServerConfigHash } from './configHash.js';
@@ -202,7 +203,9 @@ export class LspServerManager {
}
const nextHash = desiredHashes.get(name);
if (this.serverConfigHashes.get(name) !== nextHash) {
if (this.serverConfigHashes.get(name) === nextHash) {
result.unchanged.push(name);
} else {
await this.abortAndWaitForStartup(handle);
await this.stopServer(name, handle);
const nextHandle: LspServerHandle = {
@@ -220,8 +223,6 @@ export class LspServerManager {
}
result.restarted.push(name);
}
} else {
result.unchanged.push(name);
}
}
@@ -263,26 +264,32 @@ export class LspServerManager {
/**
* Ensure tsserver has at least one file open so navto/navtree requests succeed.
* Sets warmedUp flag only after successful warm-up to allow retry on failure.
* Latches unsupported attempts to avoid repeated discovery; delivery failures retry.
*
* @param handle - The LSP server handle
* @param force - Force re-warmup even if already warmed up
* @returns The URI of the file opened during warmup, or undefined if no file was opened
* @param synchronizeDocument - Service-owned synchronization of the warmup file
*/
async warmupTypescriptServer(
handle: LspServerHandle,
synchronizeDocument: (uri: string, languageId: string) => boolean,
force = false,
): Promise<string | undefined> {
): Promise<void> {
if (!handle.connection || !this.isTypescriptServer(handle)) {
return undefined;
return;
}
if (handle.warmedUp && !force) {
return undefined;
return;
}
const connection = handle.connection;
const tsFile = this.findFirstTypescriptFile();
if (!tsFile) {
return undefined;
return;
}
// A failed forced attempt must stay retryable instead of latching warm. Kept
// below the discovery guard so a forced attempt that never reaches delivery
// (no TypeScript file found) cannot permanently destroy an established latch.
if (force) handle.warmedUp = false;
const uri = pathToFileURL(tsFile).toString();
const languageId = tsFile.endsWith('.tsx')
@@ -293,30 +300,27 @@ export class LspServerManager {
? 'javascript'
: 'typescript';
try {
const text = fs.readFileSync(tsFile, 'utf-8');
handle.connection.send({
jsonrpc: '2.0',
method: 'textDocument/didOpen',
params: {
textDocument: {
uri,
languageId,
version: 1,
text,
},
},
});
const sent = synchronizeDocument(uri, languageId);
const { change, openClose } = resolveTextDocumentSync(
handle.textDocumentSync,
);
if (!sent && (!openClose || (force && change !== 1 && change !== 2))) {
debugLogger.warn(
`TypeScript server ${handle.config.name} warm-up delivered no notification (textDocumentSync=${JSON.stringify(handle.textDocumentSync)})`,
);
handle.warmedUp = true;
return;
}
// Give tsserver a moment to build the project.
await new Promise((resolve) =>
setTimeout(resolve, DEFAULT_LSP_WARMUP_DELAY_MS),
);
// Only mark as warmed up after successful completion
handle.warmedUp = true;
return uri;
// Only mark the connection whose warmup has actually settled.
if (handle.connection === connection) handle.warmedUp = true;
} catch (error) {
// Do not set warmedUp to true on failure, allowing retry
debugLogger.warn('TypeScript server warm-up failed:', error);
return undefined;
return;
}
}
@@ -443,6 +447,7 @@ export class LspServerManager {
handle.error = undefined;
handle.processDiagnostics = undefined;
handle.warmedUp = false;
handle.textDocumentSync = undefined;
handle.status = 'IN_PROGRESS';
debugLogger.info(
`Starting LSP server ${name}: command=${
@@ -464,7 +469,7 @@ export class LspServerManager {
const startupExit = this.createStartupExitWatcher(name, handle);
try {
// Initialize LSP server
await this.raceStartupAbort(
handle.textDocumentSync = await this.raceStartupAbort(
this.initializeLspServer(connection, handle.config),
startupAbortController.signal,
undefined,
@@ -1152,7 +1157,7 @@ export class LspServerManager {
private async initializeLspServer(
connection: LspConnectionResult,
config: LspServerConfig,
): Promise<void> {
): Promise<LspTextDocumentSync | undefined> {
const workspaceFolderPath = config.workspaceFolder ?? this.workspaceRoot;
const workspaceFolder = {
name: path.basename(workspaceFolderPath) || workspaceFolderPath,
@@ -1180,7 +1185,9 @@ export class LspServerManager {
initializationOptions: config.initializationOptions,
};
await connection.initialize(initializeParams);
const result = (await connection.initialize(initializeParams)) as
| { capabilities?: { textDocumentSync?: LspTextDocumentSync } }
| undefined;
// Send initialized notification and workspace folders change to help servers (e.g. tsserver)
// create projects in the correct workspace.
@@ -1210,9 +1217,7 @@ export class LspServerManager {
});
}
// Note: TypeScript server warm-up is handled by warmupTypescriptServer()
// which is called before every LSP request. This avoids duplicate
// textDocument/didOpen notifications that aren't tracked in openedDocuments.
return result?.capabilities?.textDocumentSync;
}
/**
File diff suppressed because it is too large. Load diff
@@ -6,7 +6,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { NativeLspService } from './NativeLspService.js';
import { NativeLspService } from './native-lsp-service.js';
import type { Config as CoreConfig } from '../config/config.js';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import type { IdeContextStore } from '../ide/ideContext.js';
@@ -5,7 +5,7 @@
*/
import { describe, beforeEach, afterEach, expect, test, vi } from 'vitest';
import { NativeLspService } from './NativeLspService.js';
import { NativeLspService } from './native-lsp-service.js';
import { EventEmitter } from 'events';
import type { Config as CoreConfig } from '../config/config.js';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
@@ -342,6 +342,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
const reconcileServerConfigs = vi.fn(async () => ({
@@ -356,11 +357,14 @@ describe('NativeLspService', () => {
getHandles: () => new Map([['typescript-language-server', handle]]),
};
const internals = service as unknown as {
openedDocuments: Map<string, Set<string>>;
openedDocuments: Map<
string,
Map<string, { text: string; version: number }>
>;
};
internals.openedDocuments.set(
'typescript-language-server',
new Set([uri]),
new Map([[uri, { text: 'old', version: 1 }]]),
);
const reinitialize = service.reinitialize();
@@ -386,6 +390,106 @@ describe('NativeLspService', () => {
}
});
test('replays URIs that were only parked when a server reloads', async () => {
vi.useFakeTimers();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-parked-'));
try {
const firstPath = path.join(tempDir, 'main.ts');
const secondPath = path.join(tempDir, 'second.ts');
const firstUri = pathToFileURL(firstPath).toString();
const secondUri = pathToFileURL(secondPath).toString();
fs.writeFileSync(firstPath, 'const value = 1;\n', 'utf-8');
fs.writeFileSync(secondPath, 'const other = 2;\n', 'utf-8');
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
command: 'typescript-language-server',
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
const connection = {
listen: vi.fn(),
send: vi.fn(),
onNotification: vi.fn(),
onRequest: vi.fn(),
request: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
end: vi.fn(),
};
const handle = {
config: {
name: 'typescript-language-server',
languages: ['typescript'],
command: 'typescript-language-server',
args: [],
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
const reconcileServerConfigs = vi.fn(async () => ({
added: [],
removed: [],
restarted: ['typescript-language-server'],
unchanged: [],
failed: [],
}));
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
getHandles: () => new Map([['typescript-language-server', handle]]),
};
const internals = service as unknown as {
openedDocuments: Map<
string,
Map<string, { text: string; version: number }>
>;
replayUris: Map<string, Set<string>>;
};
internals.openedDocuments.set(
'typescript-language-server',
new Map([[firstUri, { text: 'old', version: 1 }]]),
);
internals.replayUris.set(
'typescript-language-server',
new Set([secondUri]),
);
const reinitialize = service.reinitialize();
await vi.runAllTimersAsync();
await reinitialize;
const openedUris = connection.send.mock.calls
.filter(([message]) => message.method === 'textDocument/didOpen')
.map(
([message]) =>
(message.params as { textDocument: { uri: string } }).textDocument
.uri,
);
expect(new Set(openedUris)).toEqual(new Set([firstUri, secondUri]));
// The reload snapshot consumed the durable set.
expect(internals.replayUris.has('typescript-language-server')).toBe(
false,
);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('reinitialize continues replaying documents after one server send fails', async () => {
vi.useFakeTimers();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-replay-'));
@@ -451,6 +555,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection: firstConnection,
},
],
@@ -465,6 +570,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection: secondConnection,
},
],
@@ -481,21 +587,30 @@ describe('NativeLspService', () => {
getHandles: () => handles,
};
const internals = service as unknown as {
openedDocuments: Map<string, Set<string>>;
openedDocuments: Map<
string,
Map<string, { text: string; version: number }>
>;
lastConnections: Map<string, unknown>;
};
internals.openedDocuments.set(
'typescript-language-server',
new Set([firstUri]),
new Map([[firstUri, { text: 'old', version: 1 }]]),
);
internals.openedDocuments.set(
'pyright-langserver',
new Map([[secondUri, { text: 'old', version: 1 }]]),
);
internals.openedDocuments.set('pyright-langserver', new Set([secondUri]));
const reinitialize = service.reinitialize();
await vi.runAllTimersAsync();
await expect(reinitialize).resolves.toBeDefined();
expect(firstConnection.send).toHaveBeenCalledOnce();
expect(internals.lastConnections.has('typescript-language-server')).toBe(
expect(internals.lastConnections.get('typescript-language-server')).toBe(
firstConnection,
);
expect(internals.openedDocuments.has('typescript-language-server')).toBe(
false,
);
expect(internals.lastConnections.get('pyright-langserver')).toBe(
@@ -563,6 +678,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
const reconcileServerConfigs = vi
@@ -586,17 +702,20 @@ describe('NativeLspService', () => {
getHandles: () => new Map([['typescript-language-server', handle]]),
};
const internals = service as unknown as {
openedDocuments: Map<string, Set<string>>;
openedDocuments: Map<
string,
Map<string, { text: string; version: number }>
>;
};
internals.openedDocuments.set(
'typescript-language-server',
new Set([uri]),
new Map([[uri, { text: 'old', version: 1 }]]),
);
await service.reinitialize();
expect(
internals.openedDocuments.get('typescript-language-server'),
).toEqual(new Set([uri]));
).toEqual(new Map([[uri, { text: 'old', version: 1 }]]));
const reinitialize = service.reinitialize();
await vi.runAllTimersAsync();
@@ -737,6 +856,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
let resolveReconcile: (
@@ -757,7 +877,10 @@ describe('NativeLspService', () => {
getHandles: () => new Map([['typescript-language-server', handle]]),
};
const internals = service as unknown as {
openedDocuments: Map<string, Set<string>>;
openedDocuments: Map<
string,
Map<string, { text: string; version: number }>
>;
};
const reinitialize = service.reinitialize();
@@ -766,7 +889,7 @@ describe('NativeLspService', () => {
});
internals.openedDocuments.set(
'typescript-language-server',
new Set([uri]),
new Map([[uri, { text: 'old', version: 1 }]]),
);
resolveReconcile!({
added: [],
@@ -840,6 +963,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
const stopAll = vi.fn(async () => {});
@@ -856,12 +980,15 @@ describe('NativeLspService', () => {
stopAll,
};
const internals = service as unknown as {
openedDocuments: Map<string, Set<string>>;
openedDocuments: Map<
string,
Map<string, { text: string; version: number }>
>;
lastConnections: Map<string, unknown>;
};
internals.openedDocuments.set(
'typescript-language-server',
new Set([uri]),
new Map([[uri, { text: 'old', version: 1 }]]),
);
internals.lastConnections.set('typescript-language-server', connection);
@@ -960,14 +1087,20 @@ describe('NativeLspService', () => {
const stopAll = vi.fn(async () => {});
const internals = service as unknown as {
serverManager: unknown;
openedDocuments: Map<string, Set<string>>;
openedDocuments: Map<
string,
Map<string, { text: string; version: number }>
>;
lastConnections: Map<string, unknown>;
};
internals.serverManager = {
getHandles: () => new Map([['tsserver', {}]]),
stopAll,
};
internals.openedDocuments.set('tsserver', new Set(['file:///a.ts']));
internals.openedDocuments.set(
'tsserver',
new Map([['file:///a.ts', { text: 'old', version: 1 }]]),
);
internals.lastConnections.set('tsserver', {});
const result = await service.reinitialize();
@@ -1087,11 +1220,17 @@ describe('NativeLspService', () => {
const stopAll = vi.fn(async () => {});
const internals = lspService as unknown as {
serverManager: { stopAll: () => Promise<void> };
openedDocuments: Map<string, Set<string>>;
openedDocuments: Map<
string,
Map<string, { text: string; version: number }>
>;
lastConnections: Map<string, unknown>;
};
internals.serverManager = { stopAll };
internals.openedDocuments.set('tsserver', new Set(['file:///a.ts']));
internals.openedDocuments.set(
'tsserver',
new Map([['file:///a.ts', { text: 'old', version: 1 }]]),
);
internals.lastConnections.set('tsserver', {});
await lspService.stop();
@@ -1118,6 +1257,7 @@ describe('NativeLspService', () => {
workspaceFolder: '/test/workspace',
},
status: 'READY',
textDocumentSync: 1,
process: { pid: 12345 },
warmedUp: true,
restartAttempts: 1,
@@ -1223,6 +1363,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
@@ -1334,6 +1475,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
@@ -1442,6 +1584,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
@@ -1516,6 +1659,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
@@ -1594,6 +1738,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection: connection1,
};
@@ -1703,6 +1848,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
@@ -1770,6 +1916,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
@@ -1856,13 +2003,16 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
// First call: warmup returns warmupUri (different from queryUri)
// Warmup delegates its open through the service-owned synchronization.
const serverManager = {
getHandles: () => new Map([['typescript', handle]]),
warmupTypescriptServer: vi.fn(async () => warmupUri),
warmupTypescriptServer: vi.fn(async (_handle, synchronizeDocument) => {
synchronizeDocument(warmupUri, 'typescript');
}),
};
(lspService as unknown as { serverManager: unknown }).serverManager =
@@ -1870,7 +2020,7 @@ describe('NativeLspService', () => {
vi.useFakeTimers();
try {
// First request: opens queryUri via ensureDocumentOpen, warmup returns warmupUri
// First request: opens queryUri via ensureDocumentSynchronized, warmup opens warmupUri
const promise1 = lspService.hover({
uri: queryUri,
range: {
@@ -1881,7 +2031,7 @@ describe('NativeLspService', () => {
await vi.runAllTimersAsync();
await promise1;
// queryUri should have been opened via ensureDocumentOpen
// queryUri should have been opened via ensureDocumentSynchronized
expect(didOpenUris).toContain(queryUri);
const countAfterFirst = didOpenUris.length;
@@ -1896,7 +2046,7 @@ describe('NativeLspService', () => {
await vi.runAllTimersAsync();
await promise2;
// warmupUri should NOT have been opened again via ensureDocumentOpen
// warmupUri should NOT have been opened again via ensureDocumentSynchronized
// because it was tracked from the warmup in the first call
expect(didOpenUris.length).toBe(countAfterFirst);
} finally {
@@ -1955,6 +2105,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
@@ -2030,6 +2181,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
@@ -2094,6 +2246,7 @@ describe('NativeLspService', () => {
transport: 'stdio',
},
status: 'READY',
textDocumentSync: 1,
connection,
};
+20
View File
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export function sortJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sortJsonValue);
}
if (value && typeof value === 'object') {
// Object.create(null) avoids prototype pollution from __proto__ keys
const sorted = Object.create(null) as Record<string, unknown>;
for (const key of Object.keys(value).sort()) {
sorted[key] = sortJsonValue((value as Record<string, unknown>)[key]);
}
return sorted;
}
return value;
}
+25
View File
@@ -69,6 +69,8 @@ export interface LspCallHierarchyItem {
range: LspRange;
/** The range that should be selected when navigating to this item. */
selectionRange: LspRange;
/** Client-issued freshness token; echo unchanged, never send to the LSP server. */
documentRevision?: string;
/** Opaque data used by the server for subsequent calls. */
data?: unknown;
/** The LSP server that provided this item. */
@@ -587,6 +589,27 @@ export interface LspProcessDiagnostics {
exitSignal?: string | null;
}
export type LspTextDocumentSync =
| 0
| 1
| 2
| { openClose?: boolean; change?: 0 | 1 | 2 };
/**
* Normalize the textDocumentSync capability into the two facts the document
* synchronizer needs. Single source of truth shared by the service (deciding
* whether to send) and the manager (deciding whether a warmup latched), so the
* two coupled decisions cannot drift apart.
*/
export function resolveTextDocumentSync(
sync: LspTextDocumentSync | undefined,
): { change: 0 | 1 | 2; openClose: boolean } {
const change = typeof sync === 'number' ? sync : (sync?.change ?? 0);
const openClose =
typeof sync === 'number' ? sync !== 0 : (sync?.openClose ?? false);
return { change, openClose };
}
/**
* Handle for managing an LSP server instance.
*/
@@ -597,6 +620,8 @@ export interface LspServerHandle {
status: LspServerStatus;
/** Active connection to the server */
connection?: LspConnectionInterface;
/** Synchronization capability from this connection's initialize response. */
textDocumentSync?: LspTextDocumentSync;
/** Server process (for stdio transport) */
process?: ChildProcess;
/** Error that caused failure */
+5
View File
@@ -1121,6 +1121,11 @@ export class LspTool extends BaseDeclarativeTool<LspToolParams, ToolResult> {
name: { type: 'string' },
kind: { type: 'string' },
rawKind: { type: 'number' },
documentRevision: {
type: 'string',
description:
'Client freshness token. Echo unchanged; if absent or stale for a file URI, prepare call hierarchy again. Non-file items cannot be traversed; prepare at a file location instead.',
},
detail: { type: 'string' },
uri: { type: 'string' },
range: { $ref: '#/definitions/LspRange' },