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

38 KiB
Raw Blame History

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-servertest_providertest_schedulertest_core_servertest_cli_startup、LSP JSON transport smoke
  • Verification Gate: 目标全部构建成功;上述 CTest 全部通过;core 无生命周期 bool、取消空分支、"{}"/固定 JSON 兜底或已删除 Provider 引用
  • Executor: executing-plans
  • Constraints: karpathy-guidelines.agentsAGENT_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 运行:

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<FixtureInitialize>());
            },
            2,
            "");
        return server.Run();
    }
}

test_main.cppm 的正常测试输出前处理该模式:

if (arg == "--core-server-fixture")
    return lsp::test::provider::RunCoreServerFixture();

test_core_server.py 添加 framing 工具和生命周期用例:

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

脚本导入 argparsejsonosselectsubprocesstimePath 解析必填 --server 后调用 assert_lifecycle(Path(args.server)) 并返回 0。响应 解析严格按 Content-Length 切分,不按换行猜测 JSON 边界。

test_provider/CMakeLists.txt 的 source 和 Module file-set 中加入 fixture、 server.cppmbridge/win32_stdio.cppmmanager/bootstrap.cppm;在父级 test/CMakeLists.txt 的 Python 分支注册:

add_test(NAME test_core_server
  COMMAND ${PYTHON3_EXECUTABLE}
    ${CMAKE_CURRENT_LIST_DIR}/test_core_server.py
    --server $<TARGET_FILE:test_provider>)
  • Step 2: 运行测试并确认 RED

Run:

cmake --build lsp-server/build/codex43-clean/Release --target test_provider tsl-server -j1

Expected: FAILLspServer 还没有 stream/registrar 构造函数且 Run() 仍返回 void;失败来自新生命周期接口,而不是 Python 语法错误。

  • Step 3: 收紧 Dispatcher 构造与错误响应接口

将 Dispatcher 改为构造即有效:

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_;
};

删除 ServerLifecycleEventLifecycleCallback、两个 setter、callback 注册/通知 和所有 nullable dependency 分支。错误构造只保留一套实现:

std::string BuildErrorResponseMessage(
    std::optional<protocol::RequestId> 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<protocol::integer>(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 引用和显式状态:

using ProviderRegistrar = std::function<void(RequestDispatcher&)>;

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();

成员按依赖顺序声明:

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<bool> fatal_io_error_ = false;

请求状态策略集中在 HandleRequest

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<protocol::ResponseMessage>(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

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 只允许 exitkRunning 才 dispatch 普通通知。Task 3 再在普通通知之前插入 $/cancelRequest 控制分支。

SendMessage 使用注入的 output stream,并把写失败标为 fatal

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、RequiresSyncProcessingCanProcessRequest 和 生命周期事件入口。

  • Step 5: 删除 lifecycle Provider 和迁移调用点

删除 shutdown/exit/cancel_request 三个 Module。AllProviders 移除 Shutdown Initialize::ProvideResponse 删除事件触发,只在序列化失败时抛出。Provider bridge 只保留实际使用的接口别名:

using ExecutionContext = lsp::core::ExecutionContext;
using IProvider = lsp::core::IProvider;
using IRequestProvider = lsp::core::IRequestProvider;
using INotificationProvider = lsp::core::INotificationProvider;
using lsp::core::BuildErrorResponseMessage;

测试环境统一改为:

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 退出码:

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:

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;生命周期用例 PASSshutdown 响应含 result: null,正常 关闭返回 0,异常退出返回 1。

  • Step 7: 提交生命周期改动

只暂存本 Task 文件;launcher 使用逐 hunk 暂存,保留原有 namespace/args_parser 改动。检查后提交:

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 测试

新增批量原始消息测试:

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:

ctest --test-dir lsp-server/build/codex43-clean/Release -R '^test_core_server$' --output-on-failure

Expected: FAIL,非法 JSON/结构当前只写日志,没有对应响应。

  • Step 3: 实现严格消息分类

HandleMessage 按以下顺序处理:

auto any = transform::Deserialize<protocol::LSPAny>(raw_message);
if (!any)
{
    SendError(std::nullopt, protocol::ErrorCodes::ParseError, "Parse error");
    return;
}
if (!any->Is<protocol::LSPObject>())
{
    SendError(std::nullopt, protocol::ErrorCodes::InvalidRequest,
              "Invalid Request");
    return;
}

const auto& object = any->Get<protocol::LSPObject>();
const auto jsonrpc = object.find("jsonrpc");
if (jsonrpc == object.end() || !jsonrpc->second.Is<protocol::string>() ||
    jsonrpc->second.Get<protocol::string>() != "2.0")
{
    SendError(ExtractRequestId(object), protocol::ErrorCodes::InvalidRequest,
              "Invalid Request");
    return;
}

ExtractRequestId 只接受 integer/string

std::optional<protocol::RequestId> ExtractRequestId(
    const protocol::LSPObject& object)
{
    const auto id = object.find("id");
    if (id == object.end())
        return std::nullopt;
    if (id->second.Is<protocol::integer>())
        return protocol::RequestId{id->second.Get<protocol::integer>()};
    if (id->second.Is<protocol::string>())
        return protocol::RequestId{id->second.Get<protocol::string>()};
    return std::nullopt;
}

method 必须是 string;有 method+id 是 request,无 id 是 notificationresponse 必须有 id 且 result/error 恰好一 个。不可分类的 response 只记录,不发送 response-to-response。

SendError 只负责调用公共 BuildErrorResponseMessageSendMessage,不再 自己构造 ResponseMessage

  • Step 4: 运行 GREEN 验证并提交

Run:

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:

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

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 中加入:

dispatcher.RegisterRequestProvider(std::make_shared<FixtureBlock>());
dispatcher.RegisterRequestProvider(std::make_shared<FixtureThrow>());
dispatcher.RegisterNotificationProvider(
    std::make_shared<FixtureThrowNotification>());

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:

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: FAILGetStopToken 尚不存在,或同步 block 请求使取消响应超过 1 秒。

  • Step 3: 向 ExecutionContext 传递 stop token
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 增加:

struct ActiveRequest
{
    scheduler::async_executor::TaskHandle handle;
};

std::mutex requests_mutex_;
std::unordered_map<std::string, std::shared_ptr<ActiveRequest>> active_requests_;

请求键必须带类型:

std::string RequestKey(const protocol::RequestId& id)
{
    return std::visit([](const auto& value) {
        using T = std::decay_t<decltype(value)>;
        if constexpr (std::is_same_v<T, protocol::integer>)
            return "i:" + std::to_string(value);
        else
            return "s:" + value;
    }, id);
}

SubmitRequest 先登记 shared state,解锁后 Submit,再绑定 handle

auto state = std::make_shared<ActiveRequest>();
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<std::string> {
        return dispatcher_.Dispatch(request, stop_token);
    },
    [this, request, key, state](const auto& result) {
        FinishRequest(request, key, state, result);
    });

FinishRequest 先仅当映射仍指向同一 state 时删除登记并释放请求表锁,再对 completed/cancelled/failed 分别发送结果、RequestCancelledInternalError。 这样输出异常不会留下陈旧登记,callback 也不会在请求表锁内发送消息。

HandleCancelRequest 严格读取 CancelParams,复制 handle 后解锁并调用 Cancel()DrainActiveRequests 锁内复制 handles、锁外 cancel/waitshutdown、 exit、EOF/framing 收尾都调用它。shutdown 随后调用 async_executor_.WaitAll(),保证 initialize/Provider 提交的后台任务也在 ManagerHub::Shutdown() 前结束。

  • Step 5: 捕获 notification 异常并运行 GREEN

notification dispatch 外围只记录异常:

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:

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: 提交异步取消改动
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 响应:

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:

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
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

主循环处理:

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:

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:

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 测试

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"};
}

在测试类声明和注册入口分别加入:

static TestResult TestBytePointsToUtf16Positions();
runner.addTest("text coordinates convert byte points to UTF-16 positions",
               TestBytePointsToUtf16Positions);

测试 helper 只比较字段:

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:

cmake --build lsp-server/build/codex43-clean/Release --target test_provider -j1

Expected: FAILToPosition(TSPoint, string) 尚未定义。

  • Step 3: 实现单一 UTF-8 解码路径的反向转换

导出:

protocol::Position ToPosition(TSPoint point,
                              const protocol::string& content);

实现先扫描到 point.row 行首,再逐个调用已有 DecodeCharacter。只有完整字符的 末尾不超过目标 byte column 时才累计 utf16_units;目标落在字符内部时停止, 超出行尾时收敛到行尾。返回实际行号和累计 UTF-16 units,不复制第二套 UTF-8 判定代码。

PublishDiagnostics 转换起止点:

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:

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:

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:

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:

cmake --build lsp-server/build/codex43-clean/Release --target tsl-server test_scheduler test_provider -j1

Expected: exit 0,无编译错误。

  • Step 3: 运行完整相关测试门

Run:

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 PASSLSP transport smoke exit 0。已知 test_ast_scripttest_symbol_scripttest_semantic_script 以及 rename 结果校验不在本 Plan gate 中。

  • Step 4: 专项搜索无效兜底、兼容层和死代码

Run:

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:

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。再运行:

rg -n 'catch\s*\(\.\.\.\)|catch\s*\(const std::exception' lsp-server/src/core

Expected: 每个 catch 都能对应“请求转 InternalError”“通知只记录”或“主循环致命 错误退出”,不存在捕获后 sleep/继续的分支。

  • Step 5: 检查最终 diff 和提交遗漏

Run:

git diff --check
git status --short
git log -6 --oneline

Expected: 本 Plan 产品/测试改动已提交;用户原有未提交文件仍存在且未混入提交; 新增提交均带匹配 type 的 emoji。

若格式化或复审产生必要修正,先重新执行 Step 2/3,再提交:

git commit -m ":art: style(core): normalize lifecycle implementation"
  • Step 6: 写回主循环状态

Run:

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 和验证结果;只暂存该文件并提交:

git add memory-bank/progress.md
git diff --cached --check
git commit -m ":memo: docs(progress): finish LSP core lifecycle plan"