Files
tsl-devkit/docs/superpowers/specs/2026-07-13-lsp-core-lifecycle-design.md
T

244 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LSP Core 生命周期、调度与传输设计
## 背景
`lsp.core.server` 当前用两个布尔值表达服务器生命周期,导致初始化前可以
`shutdown`、可以重复 `initialize`,且 `shutdown` 响应后立即结束主循环,
没有按 LSP 要求等待 `exit`。普通请求在输入线程同步执行,因此服务器执行
Provider 时无法读取 `$/cancelRequest`;现有取消入口没有实际取消逻辑。
消息入口和 Dispatcher 还存在几类协议边界问题:非法 JSON 与非法请求只记录
日志,Provider 异常没有生成请求错误响应,依赖缺失或序列化失败使用 `"{}"`
伪响应,`Content-Length` 解析宽松且没有消息大小上限。生命周期 callback 在
锁内执行,也允许重入死锁。诊断发布器虽然暂未启用,但会把 Tree-sitter 的
UTF-8 字节列直接作为 LSP UTF-16 character。
本设计直接建立严格、单一的 LSP 3.17 行为,不保留原 API、旧生命周期行为、
宽松 framing、Provider 形式的 `exit`/`$/cancelRequest` 或任何兼容适配层。
## 目标
- 用显式状态机约束 `initialize``shutdown``exit`
- 正常关闭等待 `exit`,并向进程入口返回准确退出码。
- 让输入线程在普通请求执行期间继续读取取消通知。
- 按请求实例精确取消,区分整数 ID 与字符串 ID。
- 为请求解析、状态、Provider 异常和取消生成规范 JSON-RPC 错误。
- 让 Dispatcher 构造后立即有效,不存在后注入依赖或伪响应。
- 严格验证 LSP framing,遇到不可恢复的传输错误立即终止。
- 删除已无必要的生命周期 callback 及其锁内调用路径。
- 将诊断位置转换为 LSP UTF-16 坐标。
- 删除无调用函数、重复错误构造和无效兜底代码。
## 非目标
- 不兼容旧的 `LspServer::Run()`、Dispatcher 默认构造或依赖 setter。
- 不接受非 LSP 3.17 framing,也不尝试从流错位中恢复。
- 不为已删除的 `shutdown``exit``cancelRequest` Provider 保留转发层。
- 不保证强制中断任意 Provider;运行中取消仍遵循 `std::stop_token` 的协作式
语义。
- 不启用当前关闭的诊断发布能力,只保证其坐标在以后启用时正确。
## 生命周期所有权
生命周期只由 `LspServer` 管理,不再由 Provider 通过 callback 间接修改。
删除 `ServerLifecycleEvent``LifecycleCallback`、Dispatcher 的生命周期 callback
注册与通知,以及 `ExecutionContext::TriggerLifecycleEvent``initialize`
Provider 只负责初始化参数、manager 状态和能力响应;`shutdown``exit`
`$/cancelRequest` 都由 core 直接处理,不再注册为 Provider。
服务器使用以下状态:
```cpp
enum class ServerState
{
kUninitialized,
kRunning,
kShutdownRequested,
kExiting,
};
```
状态只在输入线程修改,因此不使用原子布尔值:
| 当前状态 | 输入 | 行为 | 新状态 |
| --- | --- | --- | --- |
| `kUninitialized` | `initialize` | 同步执行并发送成功响应 | `kRunning` |
| `kUninitialized` | 其他请求 | `ServerNotInitialized` | 不变 |
| `kUninitialized` | `exit` | 结束主循环,返回 1 | `kExiting` |
| `kRunning` | 重复 `initialize` | `InvalidRequest` | 不变 |
| `kRunning` | `shutdown` | 取消并等待普通请求,关闭 manager,发送响应 | `kShutdownRequested` |
| `kRunning` | `exit` | 结束主循环,返回 1 | `kExiting` |
| `kShutdownRequested` | `exit` | 结束主循环,返回 0 | `kExiting` |
| `kShutdownRequested` | 请求 | `InvalidRequest` | 不变 |
| `kShutdownRequested` | 其他通知 | 忽略并记录 | 不变 |
`initialize` 只有在 Provider 正常返回并成功发送响应后才转换状态;异常或错误
响应不推进状态。`shutdown` 完成请求排空和 manager shutdown 并成功发送响应后
才转换状态。`initialized` 只在 `kRunning` 接受。EOF、短消息体和 framing 错误
均属于非正常退出,返回 1。
`LspServer::Run()` 改为返回 `int`。launcher 直接使用该退出码,不再在
`Run()` 返回后无条件报告“正常停止”。`exit` Provider 中的 sleep 和
`std::exit()` 被删除,进程退出统一沿 `Run()` → launcher → `main()` 返回。
无论收到正常或异常 `exit`,core 都先取消并等待仍活动的普通请求,再从
`Run()` 返回;异常 EOF/framing 路径执行相同的任务收尾,但不把它误报为正常
关闭。
## 请求执行与取消
`initialize``shutdown` 在输入线程同步执行,因为它们改变全局生命周期。
普通请求提交给 `AsyncExecutor``exit``$/cancelRequest` 是 core 控制消息,
在输入线程立即处理;其他通知仍按输入顺序同步分发,避免 `didOpen`
`didChange` 与后续请求发生人为重排。
请求键保留 ID 类型:
- 整数 ID `1` 映射为 `i:1`
- 字符串 ID `"1"` 映射为 `s:1`
任务 ID 使用 core 专属前缀,例如 `lsp-request:i:1`,避免与 Provider 自己提交
的后台任务冲突。服务器在 mutex 保护的映射中保存
`request key -> shared request state`,其中包含该请求的 `TaskHandle`。输入线程
先登记 request state,再调用 `Submit`,并在重新读取输入前绑定返回的 handle;
完成 callback 按 state 身份注销映射。这样快速完成或同步提交失败都不会造成
“callback 先注销、输入线程后插入”的陈旧句柄,也不需要持有映射锁调用
`Submit`。同一类型、同一 ID 尚未完成时再次出现,按 `InvalidRequest` 拒绝,
不替换或误取消旧请求。
异步任务调用 Dispatcher 并返回序列化响应。callback 根据 `TaskResult` 统一
收尾:
- `kCompleted`:发送 Provider 响应。
- `kCancelled`:发送 `RequestCancelled`
- `kFailed`:记录原始异常并发送 `InternalError`
`$/cancelRequest` 严格反序列化 `CancelParams`,按带类型的键查找当前句柄并
调用 `TaskHandle::Cancel()`。找不到或任务已经完成时只记录,不生成通知响应。
Dispatcher 将当前请求的 `std::stop_token` 放入 `ExecutionContext`;可协作
取消的 Provider 和 manager 从 context 取得并向长循环传递。任务开始前、
Provider 返回后都由 `AsyncExecutor` 再检查停止状态,保证已取消请求不会发送
成功结果。
收到合法 `shutdown` 时,服务器先复制全部活动请求句柄,在锁外逐个请求取消
并等待 callback 完成,再直接调用 `ManagerHub::Shutdown()` 并构造 null result
响应。这样 manager shutdown 不会与普通请求继续访问共享状态并发发生。等待
期间不持有请求映射锁或输出锁。
## Dispatcher 与错误构造
`RequestDispatcher` 构造函数强制接收 `AsyncExecutor&``ManagerHub&`
`LspServer``ManagerHub``AsyncExecutor`、Dispatcher 的依赖顺序声明和构造
成员。删除默认构造、`SetRequestScheduler``SetManagerHub`、空指针检查和
`"{}"` 返回值。
Dispatcher 只负责查找 Provider、创建 `ExecutionContext` 并调用 Provider
- 未注册请求生成 `MethodNotFound`
- 未注册通知只记录日志。
- Provider 异常不在 Dispatcher 中吞掉,交给请求任务 callback 转换为
`InternalError`;通知入口捕获异常并只记录日志。
错误响应由一个公共构造函数生成,接收可选 Request ID、错误码和消息。
Provider 与 server 共用该实现,删除 `SendError`
`BuildErrorResponseMessage` 的重复组装逻辑。已识别请求回显其 ID;无法识别
ID 时序列化为 `null`
错误响应自身序列化失败视为内部不变量破坏并抛出,不返回 `"{}"`、手写固定
JSON 或空字符串。外层只在能够构造规范响应时继续运行,不增加第二套序列化
兜底。
## JSON-RPC 消息验证
`HandleMessage` 先区分 JSON 语法错误与消息结构错误:
| 输入 | 处理 |
| --- | --- |
| 非法 JSON | `ParseError`ID 为 `null` |
| 合法 JSON,但顶层不是对象 | `InvalidRequest`ID 为 `null` |
| JSON 对象但不满足 JSON-RPC 2.0 消息结构 | `InvalidRequest` |
| 请求可识别 ID,但 Request 反序列化失败 | `InvalidRequest`,回显有效 ID |
| 合法请求的 Provider 抛异常 | `InternalError`,回显请求 ID |
| 合法通知的 Provider 抛异常 | 只记录,不发送响应 |
| 合法客户端响应 | 记录或交给现有响应入口,不回送响应 |
请求、通知和响应必须显式携带 `"jsonrpc":"2.0"`。Request ID 只接受协议已
定义的整数或字符串。响应必须恰好包含 `result``error` 之一。无法可靠
分类为请求的非法客户端响应不触发响应,避免形成 response-to-response 循环。
## 严格 framing
输入只接受 LSP 3.17 header/body framing
- header 行和 header 结束符必须使用 `\r\n`
- 必须且只能出现一个 `Content-Length`
- 长度使用 `std::from_chars` 解析十进制数字,并要求完整消费字段值。
- 长度必须大于 0 且不超过 16 MiB。
- 可接受规范定义的可选
`Content-Type: application/vscode-jsonrpc; charset=utf-8`;未知、重复或格式
错误字段视为 framing 错误,不接受旧 charset 拼写。
- 消息体必须精确读满声明长度。
读取结果明确区分“完整消息”“EOF”和“致命 framing 错误”。EOF 或致命错误
立即退出主循环,不 sleep、不继续扫描、不尝试重新同步流。输出继续由单一
mutex 串行写入并检查 stream 状态;同步写失败直接向 launcher 传播,异步
callback 写失败记录 fatal I/O 状态,主循环在下一控制点以 1 退出。
## Diagnostics 坐标
`lsp.utils.text_coordinates` 增加 Tree-sitter UTF-8 字节点位到 LSP UTF-16
`Position` 的反向转换。转换按目标行扫描完整 UTF-8 字符,并累计 UTF-16 code
unit;目标字节列落在多字节字符内部时收敛到该字符起点,超过行尾时收敛到
行尾。
`PublishDiagnostics` 使用该接口分别转换语法错误起止位置,不再直接复制
`start_column`/`end_column`。转换复用已有 UTF-8 解码规则,不在 server 中
维护第二套编码逻辑。
## 清理范围
- 删除 `RequiresSyncProcessing``CanProcessRequest`,状态策略只保留一个
实际调用入口。
- 删除 Dispatcher 生命周期 callback 及其 mutex。
- 删除不再注册且由 core 直接处理的 `provider/shutdown``provider/exit`
`provider/cancel_request` Module,并同步 Provider registry 与 CMake Module
列表。
- 删除 initialize Provider 的生命周期事件代码。
- 合并错误响应构造,删除 `"{}"`、固定 JSON、空响应和 sleep 重试兜底。
- `SetupLogger` 保持独立函数;它职责明确,不属于本次 core 重构。
## 测试设计
测试遵循 TDD,每项先在旧实现上观察预期失败:
- initialize 前普通请求返回 `ServerNotInitialized`
- initialize 前 shutdown 被拒绝;重复 initialize 被拒绝。
- shutdown 响应后仍等待 exit。
- `shutdown -> exit` 返回 0,直接 exit、shutdown 后 EOF 和普通 EOF 返回 1。
- 非法 JSON 返回 `ParseError`,非法 JSON-RPC 对象返回 `InvalidRequest`
- 未注册请求返回 `MethodNotFound`
- 抛异常请求返回 `InternalError`;抛异常通知不产生响应。
- 普通请求不会阻塞主循环读取 `$/cancelRequest`
- 取消整数 ID 与字符串 ID 不冲突,取消只影响对应请求实例。
- 已取消请求返回 `RequestCancelled`,完成请求不会被迟到取消改写。
- shutdown 取消并等待所有活动普通请求。
- 缺失、重复、非数字、部分数字、零、超限 `Content-Length` 和短消息体导致
非零退出;规范消息仍可连续读取。
- callback 重入不再存在死锁路径,因为生命周期 callback 机制已删除。
- 中文和 emoji 前后的 diagnostic character 使用 UTF-16 code unit。
优先扩展 `test_provider` 和服务器进程级 JSON 测试,直接验证真实 framing、
响应和退出码。完成后构建 `tsl-server`,运行 scheduler、provider 与 LSP JSON
相关测试。已知无关的 AST、symbol、semantic 脚本失败不作为本任务的通过依据。
## 完成条件
- 生命周期只能沿设计表转换,退出码与关闭顺序一致。
- 普通请求异步执行,取消通知能在请求运行期间被读取并精确定位任务。
- 所有请求错误都产生合法 JSON-RPC 响应,通知错误不产生响应。
- Dispatcher 不存在无效中间状态或伪响应。
- framing 错误不会进入 sleep/重试/继续读取分支。
- diagnostics 使用 UTF-16 character。
- core 中不存在原两个状态 bool、未使用检查函数、空取消分支、生命周期 callback
`"{}"`/固定 JSON 兜底。
- 相关构建和测试通过,并完成一次针对无效兜底与临时兼容层的专项复审。