From 5edd4b3637901b60ef6315c0444ed84af1ff3684 Mon Sep 17 00:00:00 2001 From: csh Date: Mon, 13 Jul 2026 20:48:14 +0800 Subject: [PATCH] :memo: docs(plan): implement strict LSP core lifecycle --- .../plans/2026-07-13-lsp-core-lifecycle.md | 1186 +++++++++++++++++ memory-bank/progress.md | 2 +- 2 files changed, 1187 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-13-lsp-core-lifecycle.md diff --git a/docs/superpowers/plans/2026-07-13-lsp-core-lifecycle.md b/docs/superpowers/plans/2026-07-13-lsp-core-lifecycle.md new file mode 100644 index 0000000..36f2fc3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-lsp-core-lifecycle.md @@ -0,0 +1,1186 @@ +# LSP Core Lifecycle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 LSP core 改为严格的生命周期状态机、可取消异步请求、规范 JSON-RPC 错误和不可恢复 framing,并移除旧 Provider/兜底路径。 + +**Architecture:** `LspServer` 独占生命周期和控制消息,Dispatcher 在构造时绑定 manager/scheduler,仅负责 Provider 路由。生产入口与测试入口分别注入 Provider 注册器;测试入口仍运行同一个 server 和 stdio framing,以确定性阻塞/异常 Provider 验证异步取消和错误响应。 + +**Tech Stack:** C++23 Modules、Glaze、Taskflow/AsyncExecutor、LSP 3.17、Tree-sitter、CMake/CTest、Python 3 + +--- + +## Plan Meta + +- **Plan Group:** `lsp-core-lifecycle` +- **Parent Plan:** none +- **Verification Scope:** `tsl-server`、`test_provider`、`test_scheduler`、`test_core_server`、`test_cli_startup`、LSP JSON transport smoke +- **Verification Gate:** 目标全部构建成功;上述 CTest 全部通过;core 无生命周期 bool、取消空分支、`"{}"`/固定 JSON 兜底或已删除 Provider 引用 +- **Executor:** `executing-plans` +- **Constraints:** `karpathy-guidelines`、`.agents`、`AGENT_RULES.md`、不保留兼容层、所有提交使用规范 emoji +- **Worktree Note:** 当前工作区已有用户改动;保留所有无关修改。`lsp-server/src/cli/launcher.cppm` 已有 namespace/args_parser 改动,提交时只暂存本 Plan 新增的 registrar 和 `Run()` 退出码 hunk。 + +### Task 1: 建立 core 生命周期所有权和可测试服务器入口 + +**Files:** + +- Create: `lsp-server/test/test_provider/core_server_fixture.cppm` +- Create: `lsp-server/test/test_core_server.py` +- Modify: `lsp-server/src/core/dispatcher.cppm` +- Modify: `lsp-server/src/core/server.cppm` +- Modify: `lsp-server/src/cli/launcher.cppm` +- Modify: `lsp-server/src/provider/base/interface.cppm` +- Modify: `lsp-server/src/provider/initialize/initialize.cppm` +- Modify: `lsp-server/src/provider/manifest.cppm` +- Delete: `lsp-server/src/provider/shutdown/shutdown.cppm` +- Delete: `lsp-server/src/provider/exit/exit.cppm` +- Delete: `lsp-server/src/provider/cancel_request/cancel_request.cppm` +- Modify: `lsp-server/test/test_provider/test_main.cppm` +- Modify: `lsp-server/test/test_provider/provider_misc_test.cppm` +- Modify: `lsp-server/test/test_provider/provider_surface_test.cppm` +- Modify: `lsp-server/test/test_provider/json_provider_coverage_test.cppm` +- Modify: `lsp-server/test/test_provider/CMakeLists.txt` +- Modify: `lsp-server/test/CMakeLists.txt` + +- [ ] **Step 1: 写生命周期 RED 测试和测试服务器模式** + +在 `core_server_fixture.cppm` 定义只用于测试进程的初始化 Provider,并通过正式 +`LspServer` 运行: + +```cpp +export module lsp.test.provider.core_server_fixture; + +import std; +import spdlog; + +import lsp.codec.facade; +import lsp.core.server; +import lsp.provider.base.interface; +import lsp.protocol; + +export namespace lsp::test::provider +{ + int RunCoreServerFixture(); +} + +namespace lsp::test::provider +{ + class FixtureInitialize final : public core::IRequestProvider + { + public: + std::string GetMethod() const override { return "initialize"; } + std::string GetProviderName() const override { return "FixtureInitialize"; } + + std::string ProvideResponse(const protocol::RequestMessage& request, + core::ExecutionContext&) override + { + protocol::ResponseMessage response; + response.id = request.id; + response.result = protocol::LSPAny(protocol::LSPObject{}); + return codec::Serialize(response).value(); + } + }; + + int RunCoreServerFixture() + { + spdlog::set_level(spdlog::level::off); + core::LspServer server( + std::cin, + std::cout, + [](core::RequestDispatcher& dispatcher) { + dispatcher.RegisterRequestProvider( + std::make_shared()); + }, + 2, + ""); + return server.Run(); + } +} +``` + +在 `test_main.cppm` 的正常测试输出前处理该模式: + +```cpp +if (arg == "--core-server-fixture") + return lsp::test::provider::RunCoreServerFixture(); +``` + +在 `test_core_server.py` 添加 framing 工具和生命周期用例: + +```python +def frame(message: dict) -> bytes: + body = json.dumps(message, separators=(",", ":")).encode("utf-8") + return f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body + + +def read_messages(data: bytes) -> list[dict]: + messages = [] + offset = 0 + while offset < len(data): + header_end = data.find(b"\r\n\r\n", offset) + assert header_end >= 0 + header = data[offset:header_end].decode("ascii") + fields = dict(line.split(": ", 1) for line in header.split("\r\n")) + length = int(fields["Content-Length"]) + body_start = header_end + 4 + body_end = body_start + length + assert body_end <= len(data) + messages.append(json.loads(data[body_start:body_end].decode("utf-8"))) + offset = body_end + return messages + + +def response_by_id(data: bytes, request_id: int | str) -> dict: + return next(message for message in read_messages(data) + if message.get("id") == request_id) + + +def run_raw(server: Path, payload: bytes) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + [str(server), "--core-server-fixture"], + input=payload, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=5, + check=False, + ) + + +def run_batch(server: Path, messages: list[dict]) -> subprocess.CompletedProcess[bytes]: + payload = b"".join(frame(message) for message in messages) + return run_raw(server, payload) + + +class LspClient: + def __init__(self, server: Path) -> None: + self.proc = subprocess.Popen( + [str(server), "--core-server-fixture"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.buffer = b"" + + def send(self, message: dict) -> None: + assert self.proc.stdin is not None + self.proc.stdin.write(frame(message)) + self.proc.stdin.flush() + + def read(self, timeout: float = 1.0) -> dict: + assert self.proc.stdout is not None + deadline = time.monotonic() + timeout + while True: + header_end = self.buffer.find(b"\r\n\r\n") + if header_end >= 0: + header = self.buffer[:header_end].decode("ascii") + fields = dict(line.split(": ", 1) + for line in header.split("\r\n")) + length = int(fields["Content-Length"]) + body_start = header_end + 4 + body_end = body_start + length + if len(self.buffer) >= body_end: + body = self.buffer[body_start:body_end] + self.buffer = self.buffer[body_end:] + return json.loads(body.decode("utf-8")) + + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("timed out waiting for LSP response") + ready, _, _ = select.select( + [self.proc.stdout.fileno()], [], [], remaining) + if not ready: + continue + chunk = os.read(self.proc.stdout.fileno(), 4096) + if not chunk: + raise RuntimeError("fixture closed stdout before a response") + self.buffer += chunk + + def close_input(self) -> int: + assert self.proc.stdin is not None + self.proc.stdin.close() + return self.proc.wait(timeout=5) + + +def assert_lifecycle(server: Path) -> None: + before_init = run_batch(server, [ + {"jsonrpc": "2.0", "id": 1, "method": "shutdown"}, + {"jsonrpc": "2.0", "method": "exit"}, + ]) + assert before_init.returncode == 1 + assert response_by_id(before_init.stdout, 1)["error"]["code"] == -32002 + + repeated = run_batch(server, [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "id": 3, "method": "shutdown"}, + {"jsonrpc": "2.0", "method": "exit"}, + ]) + assert repeated.returncode == 0 + assert response_by_id(repeated.stdout, 2)["error"]["code"] == -32600 + assert response_by_id(repeated.stdout, 3)["result"] is None + + direct_exit = run_batch( + server, [{"jsonrpc": "2.0", "method": "exit"}]) + assert direct_exit.returncode == 1 + + client = LspClient(server) + client.send({"jsonrpc": "2.0", "id": 11, + "method": "initialize", "params": {}}) + assert client.read()["id"] == 11 + client.send({"jsonrpc": "2.0", "id": 12, "method": "shutdown"}) + assert client.read()["result"] is None + assert client.proc.poll() is None + client.send({"jsonrpc": "2.0", "method": "exit"}) + assert client.close_input() == 0 +``` + +脚本导入 `argparse`、`json`、`os`、`select`、`subprocess`、`time` 和 `Path`, +解析必填 `--server` 后调用 `assert_lifecycle(Path(args.server))` 并返回 0。响应 +解析严格按 `Content-Length` 切分,不按换行猜测 JSON 边界。 + +在 `test_provider/CMakeLists.txt` 的 source 和 Module file-set 中加入 fixture、 +`server.cppm`、`bridge/win32_stdio.cppm`、`manager/bootstrap.cppm`;在父级 +`test/CMakeLists.txt` 的 Python 分支注册: + +```cmake +add_test(NAME test_core_server + COMMAND ${PYTHON3_EXECUTABLE} + ${CMAKE_CURRENT_LIST_DIR}/test_core_server.py + --server $) +``` + +- [ ] **Step 2: 运行测试并确认 RED** + +Run: + +```bash +cmake --build lsp-server/build/codex43-clean/Release --target test_provider tsl-server -j1 +``` + +Expected: FAIL,`LspServer` 还没有 stream/registrar 构造函数且 `Run()` 仍返回 +`void`;失败来自新生命周期接口,而不是 Python 语法错误。 + +- [ ] **Step 3: 收紧 Dispatcher 构造与错误响应接口** + +将 Dispatcher 改为构造即有效: + +```cpp +class ExecutionContext +{ +public: + ExecutionContext(scheduler::async_executor::AsyncExecutor& scheduler, + manager::ManagerHub& manager_hub) + : async_executor_(scheduler), manager_hub_(manager_hub) {} + + scheduler::async_executor::AsyncExecutor& GetScheduler() const; + manager::ManagerHub& GetManagerHub() const; + +private: + scheduler::async_executor::AsyncExecutor& async_executor_; + manager::ManagerHub& manager_hub_; +}; + +class RequestDispatcher +{ +public: + RequestDispatcher(scheduler::async_executor::AsyncExecutor& scheduler, + manager::ManagerHub& manager_hub); + +private: + scheduler::async_executor::AsyncExecutor& async_executor_; + manager::ManagerHub& manager_hub_; +}; +``` + +删除 `ServerLifecycleEvent`、`LifecycleCallback`、两个 setter、callback 注册/通知 +和所有 nullable dependency 分支。错误构造只保留一套实现: + +```cpp +std::string BuildErrorResponseMessage( + std::optional id, + protocol::ErrorCodes code, + std::string_view message) +{ + protocol::ResponseMessage response; + response.id = std::move(id); + response.error = protocol::ResponseError{ + .jsonrpc = "2.0", + .code = static_cast(code), + .message = std::string(message), + .data = std::nullopt, + }; + return transform::Serialize(response).value(); +} + +std::string BuildErrorResponseMessage( + const protocol::RequestMessage& request, + protocol::ErrorCodes code, + std::string_view message) +{ + return BuildErrorResponseMessage(request.id, code, message); +} +``` + +`HandleUnknownRequest` 调用该函数;序列化失败直接抛出,不返回 `"{}"` 或固定 +JSON。 + +- [ ] **Step 4: 实现 server 状态机和控制消息** + +增加可注入注册器、stream 引用和显式状态: + +```cpp +using ProviderRegistrar = std::function; + +enum class ServerState +{ + kUninitialized, + kRunning, + kShutdownRequested, + kExiting, +}; + +LspServer(std::istream& input, + std::ostream& output, + ProviderRegistrar registrar, + std::size_t concurrency, + std::string interpreter_path); + +int Run(); +``` + +成员按依赖顺序声明: + +```cpp +std::istream& input_; +std::ostream& output_; +manager::ManagerHub manager_hub_; +scheduler::async_executor::AsyncExecutor async_executor_; +RequestDispatcher dispatcher_; +ServerState state_ = ServerState::kUninitialized; +int exit_code_ = 1; +std::atomic fatal_io_error_ = false; +``` + +请求状态策略集中在 `HandleRequest`: + +```cpp +if (request.method == "initialize") +{ + if (state_ != ServerState::kUninitialized) + return SendError(request, protocol::ErrorCodes::InvalidRequest, + "Server is already initialized"); + + const auto response = dispatcher_.Dispatch(request); + SendMessage(response); + const auto parsed = transform::Deserialize(response); + if (!parsed || parsed->error) + return; + state_ = ServerState::kRunning; + return; +} + +if (request.method == "shutdown") +{ + if (state_ == ServerState::kUninitialized) + return SendError(request, protocol::ErrorCodes::ServerNotInitialized, + "Server not initialized"); + if (state_ != ServerState::kRunning) + return SendError(request, protocol::ErrorCodes::InvalidRequest, + "Shutdown already requested"); + + async_executor_.WaitAll(); + manager_hub_.Shutdown(); + protocol::ResponseMessage response; + response.id = request.id; + response.result = protocol::LSPAny(std::nullptr_t{}); + SendMessage(transform::Serialize(response).value()); + state_ = ServerState::kShutdownRequested; + return; +} +``` + +生命周期控制分支之后,普通请求只允许在 `kRunning`: + +```cpp +if (state_ == ServerState::kUninitialized) +{ + SendError(request, protocol::ErrorCodes::ServerNotInitialized, + "Server not initialized"); + return; +} +if (state_ == ServerState::kShutdownRequested) +{ + SendError(request, protocol::ErrorCodes::InvalidRequest, + "Server is shutting down"); + return; +} +SendMessage(dispatcher_.Dispatch(request)); +``` + +notification 策略只有一个入口:`exit` 在任何状态都终止;`kUninitialized` 忽略 +所有其他通知;`kShutdownRequested` 只允许 `exit`;`kRunning` 才 dispatch +普通通知。Task 3 再在普通通知之前插入 `$/cancelRequest` 控制分支。 + +`SendMessage` 使用注入的 output stream,并把写失败标为 fatal: + +```cpp +void LspServer::SendMessage(const std::string& message) +{ + std::lock_guard lock(output_mutex_); + output_ << "Content-Length: " << message.size() << "\r\n\r\n" + << message << std::flush; + if (!output_) + { + fatal_io_error_ = true; + throw std::runtime_error("Failed to write LSP message"); + } +} +``` + +主循环每次读取前检查 `fatal_io_error_` 并以 1 结束。同步写异常传播到 launcher; +异步 callback 写异常由 executor 记录,但 fatal flag 会在主循环下一控制点生效。 + +`exit` 不再 dispatch:保存旧状态,设置 `kExiting`,只有旧状态为 +`kShutdownRequested` 时把 `exit_code_` 设为 0。EOF 保持 1。删除析构函数中的 +状态写入、主循环 sleep、`RequiresSyncProcessing`、`CanProcessRequest` 和 +生命周期事件入口。 + +- [ ] **Step 5: 删除 lifecycle Provider 和迁移调用点** + +删除 shutdown/exit/cancel_request 三个 Module。`AllProviders` 移除 `Shutdown`; +`Initialize::ProvideResponse` 删除事件触发,只在序列化失败时抛出。Provider +bridge 只保留实际使用的接口别名: + +```cpp +using ExecutionContext = lsp::core::ExecutionContext; +using IProvider = lsp::core::IProvider; +using IRequestProvider = lsp::core::IRequestProvider; +using INotificationProvider = lsp::core::INotificationProvider; +using lsp::core::BuildErrorResponseMessage; +``` + +测试环境统一改为: + +```cpp +struct ProviderEnv +{ + scheduler::async_executor::AsyncExecutor scheduler{1}; + manager::ManagerHub hub{}; + core::RequestDispatcher dispatcher{scheduler, hub}; + core::ExecutionContext context{scheduler, hub}; +}; +``` + +删除 provider misc/surface 中三个已删除 Provider 的 import、声明、注册、子进程 +入口和断言;Initialize 测试只验证响应与 manager 状态。同步移除 test CMake +中的三个 Module。 + +launcher 显式注入正式注册器并返回 server 退出码: + +```cpp +core::LspServer server( + std::cin, + std::cout, + provider::RegisterAllProviders, + config.thread_count, + config.interpreter_path); +const int exit_code = server.Run(); +spdlog::shutdown(); +return exit_code; +``` + +- [ ] **Step 6: 运行 GREEN 验证** + +Run: + +```bash +cmake --build lsp-server/build/codex43-clean/Release --target test_provider tsl-server -j1 +ctest --test-dir lsp-server/build/codex43-clean/Release -R '^(test_provider|test_core_server)$' --output-on-failure +``` + +Expected: build PASS;生命周期用例 PASS,shutdown 响应含 `result: null`,正常 +关闭返回 0,异常退出返回 1。 + +- [ ] **Step 7: 提交生命周期改动** + +只暂存本 Task 文件;launcher 使用逐 hunk 暂存,保留原有 namespace/args_parser +改动。检查后提交: + +```bash +git diff --cached --check +git commit -m ":bug: fix(core): enforce strict LSP lifecycle" +``` + +### Task 2: 严格分类 JSON-RPC 消息并统一请求错误 + +**Files:** + +- Modify: `lsp-server/test/test_core_server.py` +- Modify: `lsp-server/src/core/server.cppm` +- Modify: `lsp-server/src/core/dispatcher.cppm` + +- [ ] **Step 1: 写 ParseError、InvalidRequest 和 MethodNotFound RED 测试** + +新增批量原始消息测试: + +```python +def raw_frame(body: bytes) -> bytes: + return f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body + + +def assert_json_rpc_errors(server: Path) -> None: + payload = b"".join([ + raw_frame(b'{"jsonrpc":"2.0",'), + raw_frame(b'[]'), + raw_frame(b'{"jsonrpc":"1.0","id":7,"method":"initialize"}'), + frame({"jsonrpc": "2.0", "id": 8, "method": "initialize", "params": {}}), + frame({"jsonrpc": "2.0", "id": 9, "method": "missing/method"}), + frame({"jsonrpc": "2.0", "id": 10, "method": "shutdown"}), + frame({"jsonrpc": "2.0", "method": "exit"}), + ]) + result = run_raw(server, payload) + assert result.returncode == 0 + responses = read_messages(result.stdout) + assert responses[0]["error"]["code"] == -32700 + assert responses[0]["id"] is None + assert responses[1]["error"]["code"] == -32600 + assert response_by_id(result.stdout, 7)["error"]["code"] == -32600 + assert response_by_id(result.stdout, 9)["error"]["code"] == -32601 +``` + +把 `assert_json_rpc_errors(server)` 加入脚本 `main()`,确保 CTest 实际执行新用例。 + +- [ ] **Step 2: 运行测试并确认 RED** + +Run: + +```bash +ctest --test-dir lsp-server/build/codex43-clean/Release -R '^test_core_server$' --output-on-failure +``` + +Expected: FAIL,非法 JSON/结构当前只写日志,没有对应响应。 + +- [ ] **Step 3: 实现严格消息分类** + +`HandleMessage` 按以下顺序处理: + +```cpp +auto any = transform::Deserialize(raw_message); +if (!any) +{ + SendError(std::nullopt, protocol::ErrorCodes::ParseError, "Parse error"); + return; +} +if (!any->Is()) +{ + SendError(std::nullopt, protocol::ErrorCodes::InvalidRequest, + "Invalid Request"); + return; +} + +const auto& object = any->Get(); +const auto jsonrpc = object.find("jsonrpc"); +if (jsonrpc == object.end() || !jsonrpc->second.Is() || + jsonrpc->second.Get() != "2.0") +{ + SendError(ExtractRequestId(object), protocol::ErrorCodes::InvalidRequest, + "Invalid Request"); + return; +} +``` + +`ExtractRequestId` 只接受 integer/string: + +```cpp +std::optional ExtractRequestId( + const protocol::LSPObject& object) +{ + const auto id = object.find("id"); + if (id == object.end()) + return std::nullopt; + if (id->second.Is()) + return protocol::RequestId{id->second.Get()}; + if (id->second.Is()) + return protocol::RequestId{id->second.Get()}; + return std::nullopt; +} +``` + +method 必须是 string;有 method+id 是 +request,无 id 是 notification;response 必须有 id 且 `result`/`error` 恰好一 +个。不可分类的 response 只记录,不发送 response-to-response。 + +`SendError` 只负责调用公共 `BuildErrorResponseMessage` 后 `SendMessage`,不再 +自己构造 `ResponseMessage`。 + +- [ ] **Step 4: 运行 GREEN 验证并提交** + +Run: + +```bash +cmake --build lsp-server/build/codex43-clean/Release --target test_provider tsl-server -j1 +ctest --test-dir lsp-server/build/codex43-clean/Release -R '^(test_provider|test_core_server)$' --output-on-failure +``` + +Expected: PASS;错误码和 ID 与测试一致。 + +Commit: + +```bash +git add lsp-server/src/core/server.cppm lsp-server/src/core/dispatcher.cppm lsp-server/test/test_core_server.py +git diff --cached --check +git commit -m ":bug: fix(core): return JSON-RPC protocol errors" +``` + +### Task 3: 异步执行普通请求并精确取消 + +**Files:** + +- Modify: `lsp-server/test/test_provider/core_server_fixture.cppm` +- Modify: `lsp-server/test/test_core_server.py` +- Modify: `lsp-server/src/core/dispatcher.cppm` +- Modify: `lsp-server/src/core/server.cppm` + +- [ ] **Step 1: 增加阻塞、异常 Provider 和取消 RED 测试** + +Fixture 注册两个请求 Provider 和一个通知 Provider: + +```cpp +class FixtureBlock final : public core::IRequestProvider +{ +public: + std::string GetMethod() const override { return "test/block"; } + std::string GetProviderName() const override { return "FixtureBlock"; } + + std::string ProvideResponse(const protocol::RequestMessage& request, + core::ExecutionContext& context) override + { + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::seconds(2); + while (!context.GetStopToken().stop_requested() && + std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + + protocol::ResponseMessage response; + response.id = request.id; + response.result = protocol::LSPAny(protocol::string("completed")); + return codec::Serialize(response).value(); + } +}; + +class FixtureThrow final : public core::IRequestProvider +{ +public: + std::string GetMethod() const override { return "test/throw"; } + std::string GetProviderName() const override { return "FixtureThrow"; } + std::string ProvideResponse(const protocol::RequestMessage&, + core::ExecutionContext&) override + { + throw std::runtime_error("fixture request failure"); + } +}; + +class FixtureThrowNotification final : public core::INotificationProvider +{ +public: + std::string GetMethod() const override { return "test/throwNotification"; } + std::string GetProviderName() const override + { + return "FixtureThrowNotification"; + } + void HandleNotification(const protocol::NotificationMessage&, + core::ExecutionContext&) override + { + throw std::runtime_error("fixture notification failure"); + } +}; +``` + +registrar 中加入: + +```cpp +dispatcher.RegisterRequestProvider(std::make_shared()); +dispatcher.RegisterRequestProvider(std::make_shared()); +dispatcher.RegisterNotificationProvider( + std::make_shared()); +``` + +Python 交互测试必须覆盖: + +```python +client = LspClient(server) +client.send({"jsonrpc": "2.0", "id": 1, + "method": "initialize", "params": {}}) +assert client.read()["id"] == 1 + +client.send({"jsonrpc": "2.0", "id": 2, "method": "test/block"}) +client.send({"jsonrpc": "2.0", "id": "2", "method": "test/block"}) +client.send({"jsonrpc": "2.0", "method": "$/cancelRequest", + "params": {"id": 2}}) +cancelled = client.read(timeout=1) +assert cancelled["id"] == 2 +assert cancelled["error"]["code"] == -32800 + +client.send({"jsonrpc": "2.0", "method": "$/cancelRequest", + "params": {"id": "2"}}) +string_cancelled = client.read(timeout=1) +assert string_cancelled["id"] == "2" +assert string_cancelled["error"]["code"] == -32800 + +client.send({"jsonrpc": "2.0", "id": 3, "method": "test/throw"}) +failed = client.read(timeout=1) +assert failed["error"]["code"] == -32603 + +client.send({"jsonrpc": "2.0", "id": 4, "method": "test/block"}) +client.send({"jsonrpc": "2.0", "id": 4, "method": "test/block"}) +duplicate = client.read(timeout=1) +assert duplicate["id"] == 4 +assert duplicate["error"]["code"] == -32600 +client.send({"jsonrpc": "2.0", "method": "$/cancelRequest", + "params": {"id": 4}}) +cancelled_original = client.read(timeout=1) +assert cancelled_original["error"]["code"] == -32800 + +client.send({"jsonrpc": "2.0", "method": "test/throwNotification"}) +client.send({"jsonrpc": "2.0", "id": 5, "method": "shutdown"}) +assert client.read(timeout=1)["id"] == 5 +client.send({"jsonrpc": "2.0", "method": "exit"}) +assert client.close_input() == 0 +``` + +把该交互过程封装为 `assert_cancellation_and_failures(server)` 并加入脚本 +`main()`。 + +- [ ] **Step 2: 运行测试并确认 RED** + +Run: + +```bash +cmake --build lsp-server/build/codex43-clean/Release --target test_provider -j1 +ctest --test-dir lsp-server/build/codex43-clean/Release -R '^test_core_server$' --output-on-failure +``` + +Expected: FAIL;`GetStopToken` 尚不存在,或同步 block 请求使取消响应超过 1 秒。 + +- [ ] **Step 3: 向 ExecutionContext 传递 stop token** + +```cpp +ExecutionContext(scheduler::async_executor::AsyncExecutor& scheduler, + manager::ManagerHub& manager_hub, + std::stop_token stop_token = {}) + : async_executor_(scheduler), + manager_hub_(manager_hub), + stop_token_(stop_token) {} + +std::stop_token GetStopToken() const { return stop_token_; } +``` + +`RequestDispatcher::Dispatch` 改为接收默认值为空的 `std::stop_token` 并用它 +构造 context;同步 initialize 和 notification 使用空 token。 + +- [ ] **Step 4: 实现类型安全请求表和异步 callback** + +server 增加: + +```cpp +struct ActiveRequest +{ + scheduler::async_executor::TaskHandle handle; +}; + +std::mutex requests_mutex_; +std::unordered_map> active_requests_; +``` + +请求键必须带类型: + +```cpp +std::string RequestKey(const protocol::RequestId& id) +{ + return std::visit([](const auto& value) { + using T = std::decay_t; + if constexpr (std::is_same_v) + return "i:" + std::to_string(value); + else + return "s:" + value; + }, id); +} +``` + +`SubmitRequest` 先登记 shared state,解锁后 Submit,再绑定 handle: + +```cpp +auto state = std::make_shared(); +bool duplicate = false; +{ + std::lock_guard lock(requests_mutex_); + if (active_requests_.contains(key)) + duplicate = true; + else + active_requests_.emplace(key, state); +} +if (duplicate) +{ + SendError(request, protocol::ErrorCodes::InvalidRequest, + "Duplicate active request id"); + return; +} + +state->handle = async_executor_.Submit( + "lsp-request:" + key, + [this, request](std::stop_token stop_token) -> std::optional { + return dispatcher_.Dispatch(request, stop_token); + }, + [this, request, key, state](const auto& result) { + FinishRequest(request, key, state, result); + }); +``` + +`FinishRequest` 先仅当映射仍指向同一 state 时删除登记并释放请求表锁,再对 +completed/cancelled/failed 分别发送结果、`RequestCancelled`、`InternalError`。 +这样输出异常不会留下陈旧登记,callback 也不会在请求表锁内发送消息。 + +`HandleCancelRequest` 严格读取 `CancelParams`,复制 handle 后解锁并调用 +`Cancel()`。`DrainActiveRequests` 锁内复制 handles、锁外 cancel/wait;shutdown、 +exit、EOF/framing 收尾都调用它。shutdown 随后调用 +`async_executor_.WaitAll()`,保证 initialize/Provider 提交的后台任务也在 +`ManagerHub::Shutdown()` 前结束。 + +- [ ] **Step 5: 捕获 notification 异常并运行 GREEN** + +notification dispatch 外围只记录异常: + +```cpp +try +{ + dispatcher_.Dispatch(notification); +} +catch (const std::exception& error) +{ + spdlog::error("Notification {} failed: {}", + notification.method, error.what()); +} +catch (...) +{ + spdlog::error("Notification {} failed with unknown exception", + notification.method); +} +``` + +Run: + +```bash +cmake --build lsp-server/build/codex43-clean/Release --target test_provider tsl-server -j1 +ctest --test-dir lsp-server/build/codex43-clean/Release -R '^(test_scheduler|test_provider|test_core_server)$' --output-on-failure +``` + +Expected: PASS;两种 ID 分别取消,异常请求返回 -32603,异常通知不终止进程。 + +- [ ] **Step 6: 提交异步取消改动** + +```bash +git add lsp-server/src/core/server.cppm lsp-server/src/core/dispatcher.cppm lsp-server/test/test_provider/core_server_fixture.cppm lsp-server/test/test_core_server.py +git diff --cached --check +git commit -m ":bug: fix(core): dispatch cancellable requests asynchronously" +``` + +### Task 4: 严格验证 LSP framing + +**Files:** + +- Modify: `lsp-server/test/test_core_server.py` +- Modify: `lsp-server/src/core/server.cppm` + +- [ ] **Step 1: 写 framing RED 测试** + +对每个 payload 启动独立 fixture 进程并要求非零退出、stdout 无 LSP 响应: + +```python +invalid_payloads = [ + b"Content-Length: 2junk\r\n\r\n{}", + b"Content-Length: 2\r\nContent-Length: 2\r\n\r\n{}", + b"Content-Length: 0\r\n\r\n", + b"Content-Length: 16777217\r\n\r\n", + b"Content-Length: 10\r\n\r\n{}", + b"Content-Length: 2\n\n{}", + b"X-Length: 2\r\n\r\n{}", + b"Content-Length: 2\r\nContent-Type: application/json\r\n\r\n{}", +] +for payload in invalid_payloads: + result = run_raw(server, payload) + assert result.returncode == 1 + assert result.stdout == b"" +``` + +另加规范 `Content-Type: application/vscode-jsonrpc; charset=utf-8` 和连续两条 +消息的通过用例,并把 `assert_framing(server)` 加入脚本 `main()`。 + +- [ ] **Step 2: 运行测试并确认 RED** + +Run: + +```bash +ctest --test-dir lsp-server/build/codex43-clean/Release -R '^test_core_server$' --output-on-failure +``` + +Expected: FAIL;部分数字、重复长度或 LF-only header 被旧读取器接受。 + +- [ ] **Step 3: 用明确读取结果和 from_chars 实现严格 framing** + +```cpp +enum class ReadStatus +{ + kMessage, + kEndOfStream, + kFramingError, +}; + +struct ReadResult +{ + ReadStatus status; + std::string message; +}; + +static constexpr std::size_t kMaxMessageSize = 16U * 1024U * 1024U; +``` + +每个 header 行必须以 `\r` 结束;移除该字符后解析。`Content-Length: ` 后的值 +非空,`from_chars` 必须满足 `error == std::errc{}` 且 pointer 到达字符串末尾。 +只允许一个 Content-Length 和最多一个精确规范 Content-Type。未知 header、空 +header、零/超限长度和短 body 返回 `kFramingError`。 + +主循环处理: + +```cpp +const auto read = ReadMessage(); +if (read.status != ReadStatus::kMessage) +{ + exit_code_ = 1; + break; +} +HandleMessage(read.message); +``` + +删除 `stoul`、nullopt 重试、5ms sleep 和 framing 错误后的继续读取。 + +- [ ] **Step 4: 运行 GREEN 并提交** + +Run: + +```bash +cmake --build lsp-server/build/codex43-clean/Release --target test_provider tsl-server -j1 +ctest --test-dir lsp-server/build/codex43-clean/Release -R '^(test_provider|test_core_server)$' --output-on-failure +python lsp-server/test/run_lsp_json_tests.py --server lsp-server/build/codex43-clean/Release/src/tsl-server --no-validate +``` + +Expected: PASS;非法 framing 全部非零退出且无响应,规范连续消息通过。 + +Commit: + +```bash +git add lsp-server/src/core/server.cppm lsp-server/test/test_core_server.py +git diff --cached --check +git commit -m ":bug: fix(core): reject invalid LSP framing" +``` + +### Task 5: 将 diagnostics 字节列转换为 UTF-16 + +**Files:** + +- Modify: `lsp-server/test/test_provider/text_coordinates_test.cppm` +- Modify: `lsp-server/src/utils/text_coordinates.cppm` +- Modify: `lsp-server/src/core/server.cppm` + +- [ ] **Step 1: 写反向坐标 RED 测试** + +```cpp +TestResult TextCoordinatesTests::TestBytePointsToUtf16Positions() +{ + const protocol::string content = "A中😀Z\n😀x"; + + ExpectLspPosition(utils::text_coordinates::ToPosition({0U, 0U}, content), + 0U, 0U); + ExpectLspPosition(utils::text_coordinates::ToPosition({0U, 4U}, content), + 0U, 2U); + ExpectLspPosition(utils::text_coordinates::ToPosition({0U, 8U}, content), + 0U, 4U); + ExpectLspPosition(utils::text_coordinates::ToPosition({1U, 4U}, content), + 1U, 2U); + ExpectLspPosition(utils::text_coordinates::ToPosition({0U, 6U}, content), + 0U, 2U); + return {"", true, "ok"}; +} +``` + +在测试类声明和注册入口分别加入: + +```cpp +static TestResult TestBytePointsToUtf16Positions(); +runner.addTest("text coordinates convert byte points to UTF-16 positions", + TestBytePointsToUtf16Positions); +``` + +测试 helper 只比较字段: + +```cpp +void ExpectLspPosition(const protocol::Position& actual, + protocol::uinteger line, + protocol::uinteger character) +{ + assertEqual(line, actual.line, "LSP line should match"); + assertEqual(character, actual.character, "LSP UTF-16 character should match"); +} +``` + +- [ ] **Step 2: 运行测试并确认 RED** + +Run: + +```bash +cmake --build lsp-server/build/codex43-clean/Release --target test_provider -j1 +``` + +Expected: FAIL,`ToPosition(TSPoint, string)` 尚未定义。 + +- [ ] **Step 3: 实现单一 UTF-8 解码路径的反向转换** + +导出: + +```cpp +protocol::Position ToPosition(TSPoint point, + const protocol::string& content); +``` + +实现先扫描到 `point.row` 行首,再逐个调用已有 `DecodeCharacter`。只有完整字符的 +末尾不超过目标 byte column 时才累计 `utf16_units`;目标落在字符内部时停止, +超出行尾时收敛到行尾。返回实际行号和累计 UTF-16 units,不复制第二套 UTF-8 +判定代码。 + +`PublishDiagnostics` 转换起止点: + +```cpp +diagnostic.range.start = utils::text_coordinates::ToPosition( + TSPoint{error.location.start_line, error.location.start_column}, content); +diagnostic.range.end = utils::text_coordinates::ToPosition( + TSPoint{error.location.end_line, error.location.end_column}, content); +``` + +- [ ] **Step 4: 运行 GREEN 并提交** + +Run: + +```bash +cmake --build lsp-server/build/codex43-clean/Release --target test_provider tsl-server -j1 +ctest --test-dir lsp-server/build/codex43-clean/Release -R '^(test_provider|test_core_server)$' --output-on-failure +``` + +Expected: PASS;中文和 emoji 坐标符合 UTF-16。 + +Commit: + +```bash +git add lsp-server/src/utils/text_coordinates.cppm lsp-server/src/core/server.cppm lsp-server/test/test_provider/text_coordinates_test.cppm +git diff --cached --check +git commit -m ":bug: fix(core): convert diagnostics to UTF-16 positions" +``` + +### Task 6: 格式化、全量验证和无效兜底专项复审 + +**Files:** + +- Modify: `memory-bank/progress.md` +- Format: 本 Plan 已修改的 C++ 文件 + +- [ ] **Step 1: 格式化本 Plan 修改的 C++ 文件** + +Run: + +```bash +clang-format -i lsp-server/src/core/dispatcher.cppm lsp-server/src/core/server.cppm lsp-server/src/provider/base/interface.cppm lsp-server/src/provider/initialize/initialize.cppm lsp-server/src/provider/manifest.cppm lsp-server/src/utils/text_coordinates.cppm lsp-server/test/test_provider/core_server_fixture.cppm lsp-server/test/test_provider/test_main.cppm lsp-server/test/test_provider/provider_misc_test.cppm lsp-server/test/test_provider/provider_surface_test.cppm lsp-server/test/test_provider/json_provider_coverage_test.cppm lsp-server/test/test_provider/text_coordinates_test.cppm +``` + +检查格式化没有改动用户的无关 hunk;若有,恢复那些 hunk 到格式化前内容,仅 +保留本 Plan 行。 + +- [ ] **Step 2: 构建所有影响目标** + +Run: + +```bash +cmake --build lsp-server/build/codex43-clean/Release --target tsl-server test_scheduler test_provider -j1 +``` + +Expected: exit 0,无编译错误。 + +- [ ] **Step 3: 运行完整相关测试门** + +Run: + +```bash +ctest --test-dir lsp-server/build/codex43-clean/Release -R '^(test_scheduler|test_provider|test_core_server|test_cli_startup)$' --output-on-failure +python lsp-server/test/run_lsp_json_tests.py --server lsp-server/build/codex43-clean/Release/src/tsl-server --no-validate +``` + +Expected: 4/4 CTest PASS,LSP transport smoke exit 0。已知 +`test_ast_script`、`test_symbol_script`、`test_semantic_script` 以及 rename +结果校验不在本 Plan gate 中。 + +- [ ] **Step 4: 专项搜索无效兜底、兼容层和死代码** + +Run: + +```bash +rg -n 'return "\{\}"|value_or\("\{\}"\)|TO[D]O|RequiresSyncProcessing|CanProcessRequest|SetRequestScheduler|SetManagerHub|ServerLifecycleEvent|LifecycleCallback|provider\.(shutdown|exit|cancel_request)' lsp-server/src/core lsp-server/src/provider lsp-server/test/test_provider +``` + +Expected: 无命中。再分别检查生产和测试 sleep: + +```bash +rg -n 'sleep_for' lsp-server/src/core +rg -n 'sleep_for' lsp-server/test/test_provider/core_server_fixture.cppm +``` + +Expected: core 无命中;fixture 恰好命中确定性阻塞 Provider。再运行: + +```bash +rg -n 'catch\s*\(\.\.\.\)|catch\s*\(const std::exception' lsp-server/src/core +``` + +Expected: 每个 catch 都能对应“请求转 InternalError”“通知只记录”或“主循环致命 +错误退出”,不存在捕获后 sleep/继续的分支。 + +- [ ] **Step 5: 检查最终 diff 和提交遗漏** + +Run: + +```bash +git diff --check +git status --short +git log -6 --oneline +``` + +Expected: 本 Plan 产品/测试改动已提交;用户原有未提交文件仍存在且未混入提交; +新增提交均带匹配 type 的 emoji。 + +若格式化或复审产生必要修正,先重新执行 Step 2/3,再提交: + +```bash +git commit -m ":art: style(core): normalize lifecycle implementation" +``` + +- [ ] **Step 6: 写回主循环状态** + +Run: + +```bash +python docs/standards/playbook/scripts/main_loop.py finish -plan docs/superpowers/plans/2026-07-13-lsp-core-lifecycle.md -status done -progress memory-bank/progress.md +``` + +更新 `memory-bank/progress.md` 上半部分摘要,记录生命周期状态机、请求取消、 +framing、UTF-16 diagnostics 和验证结果;只暂存该文件并提交: + +```bash +git add memory-bank/progress.md +git diff --cached --check +git commit -m ":memo: docs(progress): finish LSP core lifecycle plan" +``` diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 42d189b..27bd1be 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -64,7 +64,7 @@ phase: planning spec: docs/superpowers/specs/2026-07-13-lsp-core-lifecycle-design.md -plan: docs/superpowers/plans/2026-07-12-async-executor-cancellation.md +plan: docs/superpowers/plans/2026-07-13-lsp-core-lifecycle.md executor: executing-plans constraints: karpathy-guidelines,.agents,AGENT_RULES