# W13.4 LSP Plugin 深度审计 **Auditor**: 黄岭 (architect-huang) **Date**: 2026-05-27 **File**: plugins/lsp/src/lsp_plugin.cpp (749 行) **Wave Coverage**: W6.1 仅审计 reader_loop (L415-459),其余 700+ 行首次审计 --- ## 0. 文件总览 749 行。模块分布: - Process 子进程 (start/stop/write/read_line/read_bytes): ~220 行 (L54-274) - Service dispatch 实现 (start/stop/open/close/diagnostics/hover/completion): ~280 行 (L461-698) - JSON-RPC 框架 (frame_message/parse_content_length/send_request/send_notification/handle_message): ~110 行 (L299-413) - reader_loop 状态机 (W6.1 已修): ~45 行 (L415-459) - State/Lifecycle/Descriptor: ~50 行 (L276-297 + L700-749) - Headers/Globals: ~44 行 (L1-53) ## 1. §5 + §8 异常安全——穿越 ABI 边界 状态: **严重违反** FAIL vtable 全部 7 个函数均以 C 函数指针暴露给 host (`dstalk_lsp_service_t`),但底层 C++ 实现大量使用 `std::string` / `json::object` / `json::parse` / `std::unique_lock` 等可抛异常类型,**零 try/catch 保护**: | 函数 | 行号 | 可抛异常操作 | |------|------|-------------| | `g_lsp_impl_start` | L467-537 | json::object, std::string, std::thread, std::unique_lock | | `g_lsp_impl_stop` | L539-566 | json::object, std::unique_lock | | `g_lsp_impl_open_document` | L568-584 | json::object | | `g_lsp_impl_close_document` | L586-598 | json::object | | `g_lsp_impl_get_diagnostics` | L600-612 | std::lock_guard, std::string | | `g_lsp_impl_get_hover` | L614-655 | json::object, json::parse, std::unique_lock, std::string | | `g_lsp_impl_get_completion` | L657-698 | 同上 | 此外 `reader_loop` (L419-459)、`handle_message` (L375-413)、`on_shutdown` (L724-730) 均无保护。 **违反代价**: OOM 或任何 `boost::json` 内部异常穿越 C 函数指针边界 → `std::terminate()` → 进程崩溃,零恢复机会。plugin-abi §8 标记此为强制规则 (§8.1 明确覆盖 service vtable 函数指针),当前代码全线违规。 对比 W11.1: context_plugin 的 trim_impl (L114-226) 同样违规,但当时仅 1 个 vtable 函数受影响。lsp_plugin 受影响面是其 7 倍。 评级: **F** --- ## 2. §9 字符串返回值生命周期 状态: **合规** PASS 三个返回 `char** json_out` 的函数: - `g_lsp_impl_get_diagnostics` (L600-612): `g_host->strdup(it->second.c_str())` 或 `g_host->strdup("[]")`,均在 mutex 锁内复制。Mode A (§9.2),调用方 `host->free` 释放。合规。 - `g_lsp_impl_get_hover` (L614-655): `g_host->strdup(json::serialize(resp["result"]).c_str())`,temporary 在 strdup 复制后销毁,指针独立。合规。 - `g_lsp_impl_get_completion` (L657-698): 同 get_hover。合规。 `dstalk_plugin_info_t` 的 name/version/description 均为 static 字面量 → Mode B。合规。 评级: **A** --- ## 3. §2 跨 DLL 堆纪律 状态: **合规** PASS 逐行检查所有 malloc/free/strdup/new/delete: | 模式 | 结果 | |------|------| | 裸 `malloc`/`free`/`new`/`delete` | 0 处 | | 裸 `strdup` | 1 处 (L143, POSIX 子进程内, fork 后 exec 前, 不跨 DLL) | | `g_host->strdup` | 4 处 (L607/609/653/696), 全部正确 | | `g_host->alloc`/`g_host->free` | 0 处 | 唯一裸 `strdup` 位于 `fork()` 后的子进程 (L127-151),该进程随即调用 `execvp()` 替换地址空间,或 `_exit(127)` 退出。不涉及跨 DLL 边界。无合规风险。 评级: **A** --- ## 4. 进程生命周期 状态: **多个问题** WARN ### 4.1 Win32: TerminateProcess 无条件调用 (L174-176) ```cpp WaitForSingleObject(hProcess, 2000); // 返回值被忽略 TerminateProcess(hProcess, 1); // 无条件调用 ``` 即使进程在 2 秒内正常退出,`TerminateProcess` 仍被调用。虽对已退出进程无害(返回 ERROR_ACCESS_DENIED),但语义错误且日志缺失。 ### 4.2 POSIX: SIGKILL 后 waitpid 可能永久阻塞 (L191-192) ```cpp kill(pid, SIGKILL); waitpid(pid, &status, 0); // 阻塞等待,无超时 ``` 若子进程处于 D 状态(不可中断睡眠),`waitpid(0)` 永不返回 → 调用线程永久挂起。 ### 4.3 POSIX 子进程: strdup 未检查 null (L143-144) `strdup(cmd)` 在 fork 后调用,若返回 NULL → `strtok(NULL, ...)` → 段错误。虽在 exec 前,但仍是未处理失败路径。 ### 4.4 start() 中不必要调用 stop() (L69) `Process::start` 开头调用 `stop()`,后者在 Win32 上有 2 秒 `WaitForSingleObject`。若首次启动,句柄均为 INVALID,无实际操作。但语义不清,且 `stop()` 应独立于 `start()` 调用。 评级: **C** --- ## 5. JSON-RPC 协议合规 状态: **部分缺失** WARN ### 5.1 Content-Length 解析 (L323-341) W6.1 已修。大小写不敏感、空白跳过、异常安全。合规。 ### 5.2 消息分发缺口 (L384-412) 当前仅处理两种消息: - Response: `id && !method` (L384) - Notification: `method && !id` (L391) **缺失**: Server→Client Request (`id && method`) 被静默忽略。LSP 规范中 server 可发送: - `window/showMessageRequest` (需客户端响应) - `workspace/applyEdit` (需客户端响应) - `client/registerCapability` - `workspace/configuration` server 请求得不到响应可能导致 LS 功能降级或阻塞。 ### 5.3 Error 对象未处理 (L651, L694) `get_hover` / `get_completion` 仅检查 `resp.contains("result")`,不检查 `"error"`。若 server 返回 `{"jsonrpc":"2.0","id":N,"error":{"code":-32601,"message":"..."}}`,函数返回 -1 但 **丢弃错误详情**。调用方无法区分超时和 server 错误。 ### 5.4 通知/响应的 ID 匹配 (L386, L388) `as_int64()` → `static_cast`: 若 server 回传 >INT_MAX 的 id 值则截断。不过当前 id 由本端控制,仅 server→client request 场景可能触发。 评级: **B** --- ## 6. 状态机 / 线程同步 状态: **死锁 + 数据竞争** FAIL ### 6.1 致命死锁: start 超时路径 (L519-L526 → L547) ``` L519: std::unique_lock lock(g_lsp.mutex); // 获取 mutex L520: g_lsp.cv.wait_for(lock, 10s, ...); // 超时返回,lock 仍持有 L526: g_lsp_impl_stop(); // → 进入 g_lsp_impl_stop L547: std::unique_lock lock(g_lsp.mutex); // 再次 lock 同一非递归 mutex → 死锁! ``` `std::mutex` 非递归,同线程二次 lock 为未定义行为(Windows/Linux 均死锁)。initialize 超时 (10s) 后触发,调用线程永久挂起。 注意: `get_hover`/`get_completion` 超时时仅返回 -1,不调 stop,无此问题。 ### 6.2 写管道无同步 (L357, L368) `send_request` 和 `send_notification` 均调用 `proc.write()` 而无 mutex 保护。若多线程并发调用 LSP 服务 → JSON 帧在管道上交错 → 协议解析失败。 ### 6.3 读管道关闭的竞态 (L558-562 vs L228) `g_lsp_impl_stop` 的时序: L558 `running=false` → L559 `proc.stop()` (关闭 hStdOut) → L562 `join`。reader 线程可能正阻塞于 `ReadFile(hStdOut)` (L228),句柄关闭触发 FALSE 返回 → `read_line` 返回 false → loop 退出 → join 成功。此竞态**被利用**来唤醒 reader,属有意设计。可工作但脆弱。 评级: **F** --- ## 7. 请求 ID 管理 状态: **潜在 UB** WARN - L284: `std::atomic next_id{1};` - L348: `int id = g_lsp.next_id.fetch_add(1);` - L484: `g_lsp.next_id = 1;` (重启用) 问题: 1. **fetch_add 溢出**: `std::atomic::fetch_add` 在有符号整型溢出时是未定义行为 (C++ 标准 [atomics.types.int]/8)。虽 2^31 次请求极难达到,但长生命期 LS 进程不排除。 2. **重启安全**: L484 重置为 1 时旧 pending 已在 L551 清除,安全。 3. **窄化转换**: L386 `as_int64()` → `int` 截断(见 §5.4)。 评级: **B** --- ## 8. 资源清理 状态: **基本正确** OK `on_shutdown` (L724-730): 检查 running → 调 `g_lsp_impl_stop` → 置 `g_host=nullptr`。路径正确。 `g_lsp_impl_stop` (L539-566): 1. shutdown request + 2s wait 2. exit notification 3. `running = false` 4. `proc.stop()` (terminate/kill 子进程) 5. `reader_thread.join()` 6. `diagnostics.clear()` 时序正确。pending_responses 在 L551 清除后,任何在 `cv.wait_for` 阻塞的调用者因 `!g_lsp.running` 被唤醒并返回 -1。无泄漏。 `g_host` 在 `on_shutdown` 与 service 函数间无同步 (同 W11.1 的 Race A)。缓解: host 保证 shutdown 前无 in-flight 调用。 评级: **B** --- ## 9. 能力协商 状态: **不完整** WARN - `initialize` 请求中声明 hover/completion/diagnostic 能力 (L491-503)。合规。 - `initialized` 通知在 initialize 响应后发送 (L533)。符合 LSP spec。 - **ServerCapabilities 被丢弃** (L529): initialize 响应仅取出后删除,server 上报的能力集完全未被读取。若 server 不支持 hover,插件不会知道,仍会发送 hover 请求。 - **rootUri 硬编码 nullptr** (L511): 多数 LS 需要 rootUri 提供项目根目录以建立索引。缺少 rootUri 可导致 go-to-definition、workspace symbols 等完全不工作。 评级: **C** --- ## TOP 3 严重问题 ### 1 — [严重] initialize 超时死锁 (L519-526 → L547) `g_lsp_impl_start` 持有 `g_lsp.mutex` (L519) 时调用 `g_lsp_impl_stop()` (L526),后者在 L547 再次 `unique_lock` 同一非递归 mutex → **自死锁,线程永久挂起**。任何 initialize 超时场景(server 10s 不响应)触发。 ### 2 — [严重] 全 vtable 无异常保护,违反 §8 (L467-698 + L724-730) 7 个 service vtable 函数 + `reader_loop` + `handle_message` + `on_shutdown` 均大量使用 `std::string` / `json::object` / `json::parse`,零 `try/catch`。OOM 或 boost::json 异常穿越 C 函数指针边界 → `std::terminate()` 进程崩溃。受影响面是 context_plugin (W11.1) 的 7 倍。 ### 3 — [高] Server→Client Request 被静默丢弃 + error 信息丢失 (L384-412 + L651/L694) `handle_message` 仅分发 response/notification,`id && method` 的 server request 直接丢弃不响应。server 可能因此阻塞(如 `window/showMessageRequest` 等待用户选择)。同时 error response 的 `error` 字段被忽略 (L651/L694),调用方无法区分超时与 server 错误。 --- ## 整体评级 | 维度 | 评级 | |------|------| | §5+§8 异常安全 (ABI) | F (全 vtable 裸奔) | | §9 字符串返回 | A (完全合规) | | §2 跨 DLL 堆纪律 | A (0 处违规) | | 进程生命周期 | C (死锁 + waitpid 阻塞 + strdup 未检查) | | JSON-RPC 协议合规 | B (分发缺口 + error 丢弃) | | 状态机 / 线程同步 | F (死锁 + pipe write 竞态) | | 请求 ID 管理 | B (fetch_add 溢出 UB) | | 资源清理 | B (路径正确,缺同步) | | 能力协商 | C (ServerCapabilities 丢弃,rootUri 硬编码 null) | | **综合** | **C** | **总评**: lsp_plugin 在堆纪律和字符串生命周期上完全合规(比 context_plugin 更干净),但 C++ 异常安全全线崩溃(7 个 vtable + 多个内部函数无保护)。最致命的是 initialize 超时自死锁——触发条件明确且结果确定(永久挂起)。JSON-RPC 协议处理不完整 (server request 丢弃) 在真实 LS 场景下会显著降级功能。这些问题集中在 280 行 dispatch 代码中,修复范围可控(约 300 行需加 try/catch 包裹 + 修复死锁 + 补充分发逻辑)。 --- ## 补充发现 (优先级低) - **`#include ` (L12)**: 将整个 Boost.JSON 实现编译进插件 DLL,增加编译时间且可能与其他插件/库的 Boost.JSON 符号冲突。应改为仅 `#include `,通过 `target_link_libraries` 链接 boost::boost (CMakeLists.txt L16 已链接)。 - **trim (L303-311) 与 context_plugin 重复**: 相同逻辑出现于 context_plugin 的 trim,两处独立维护。建议提取到 dstalk 核心库。 - **MultiByteToWideChar 缓冲区硬编码 4096 (L95)**: 命令行超长时静默截断,进程可能无法正确启动。 - **JSON-RPC "id" 同时接受 null**: LSP 规范允许 `"id": null` 表示通知。当前用 `msg.contains("id")` 仅检查 key 存在,未检查 null 值。若 server 误发 null-id response,会被当作有效响应。暂非实际问题(主流 LS 实现均守约)。 - **initialize 超时后 g_lsp_impl_stop 仍发 shutdown/exit 给可能未初始化的 LS**: 若 server 启动但未完成 initialize 握手,收到 shutdown 行为未定义。但在实践中,多数 LS 仍能处理。 - **Process::read_bytes L249 `buf.resize(count+1)` 后 `buf[count]='\0'`**: 刻意添加 null terminator,虽 `std::string` 不要求 null 终止,但后续 `json::parse(buf)` 需要。为性能优化(避免额外拷贝),可接受。