diff --git a/docs/superpowers/specs/2026-07-12-async-executor-cancellation-design.md b/docs/superpowers/specs/2026-07-12-async-executor-cancellation-design.md new file mode 100644 index 0000000..8c55913 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-async-executor-cancellation-design.md @@ -0,0 +1,202 @@ +# AsyncExecutor 协作式取消设计 + +## 背景 + +`lsp.scheduler.async_executor` 当前通过 Taskflow 执行后台任务,但任务句柄只 +弱引用执行状态,快速任务完成后句柄立即失效;句柄取消按任务 ID 回查,可能 +误取消同 ID 的替代任务;排队任务即使已取消仍会执行。任务注册表同时承担 +“当前 ID”与“全部活跃任务”两种职责,导致等待、统计和重复 ID 语义不稳定。 + +本设计不保留现有 API 兼容层,直接建立明确的任务生命周期、结果和协作式 +取消契约。 + +## 目标 + +- 任务句柄在任务完成后仍可等待并读取最终结果。 +- 句柄取消只影响该句柄对应的任务实例。 +- 按 ID 取消只影响该 ID 当前注册的任务实例。 +- 已取消的排队任务不执行任务体。 +- 运行中的长任务通过 `std::stop_token` 协作退出。 +- 重复 ID 的全部活跃任务被准确等待和计数。 +- 任务成功、取消和失败使用单一结果模型表达。 +- 隐藏执行状态、同步原语和句柄构造细节。 +- API namespace 与 Module 名保持一致。 + +## 非目标 + +- 不强制终止正在运行的线程。 +- 不为旧的 `TaskClosure`、callback 或 `WaitForTask` 提供兼容重载。 +- 不在本任务中启用 LSP `$/cancelRequest` 的请求级异步分发;本设计只保证 + scheduler 及其后台索引任务具备正确取消能力。 +- 不修改 Taskflow 本身。 + +## 公共 API + +Module 名保持: + +```cpp +export module lsp.scheduler.async_executor; +``` + +公共 API 迁移到: + +```cpp +namespace lsp::scheduler::async_executor +``` + +任务结果统一为: + +```cpp +enum class TaskStatus +{ + kCompleted, + kCancelled, + kFailed, +}; + +struct TaskResult +{ + TaskStatus status; + std::optional value; + std::exception_ptr error; +}; +``` + +任务 closure 只产生业务值,最终状态由 executor 根据停止请求和异常统一 +生成,避免任务体自行构造相互矛盾的状态: + +```cpp +using TaskClosure = + std::function(std::stop_token)>; +using TaskCallback = std::function; +``` + +`TaskHandle` 对外提供: + +```cpp +bool Valid() const; +bool Cancel() const; +std::optional Wait() const; +std::optional TryGetResult() const; +``` + +- 默认构造句柄无效,`Wait` 与 `TryGetResult` 返回 `std::nullopt`。 +- 有效句柄完成后仍保持有效。 +- `Cancel` 仅在首次成功请求取消且任务尚未完成时返回 `true`。 +- `Wait` 等待任务体和 callback 都结束,并返回最终任务结果。 +- `TryGetResult` 不阻塞;尚未完成时返回 `std::nullopt`。 +- 构造函数为私有,仅 `AsyncExecutor` 可以创建有效句柄。 + +删除 `AsyncExecutor::WaitForTask`。调用方必须保存 `TaskHandle` 来等待特定 +任务;按字符串 ID 只用于取消当前实例,不承担历史结果查询。 + +## 内部状态与所有权 + +`TaskHandle` 通过 `std::shared_ptr` 强持有私有嵌套状态。状态至少包含: + +- `std::stop_source`:生成 stop token 并接受取消请求。 +- `TaskPhase`:`kPending`、`kRunning`、`kCompleted`。 +- `std::optional`:最终结果。 +- mutex、condition variable 和 callback 完成标记。 +- task ID 与开始时间。 + +状态定义不导出,外部不能构造或修改同步字段。 + +`AsyncExecutor` 使用两份索引: + +- `current_tasks_`:`task_id -> state`,只表示该 ID 当前实例,供 + `Cancel(id)` 使用。 +- `active_tasks_`:以任务实例唯一标识保存全部未完成状态,供 + `WaitAll`、`GetRunningTaskCount` 与统计使用。 + +提交同 ID 新任务时,请求旧实例停止,然后替换 `current_tasks_`;旧实例仍 +留在 `active_tasks_`,直到任务体和 callback 都完成。 + +## 取消与执行流程 + +### 排队取消 + +worker 获取任务状态后,在持锁状态下检查 stop token: + +- 已请求停止:不调用 closure,直接产生 `kCancelled`。 +- 未请求停止:状态从 `kPending` 转为 `kRunning`,随后调用 closure。 + +因此取消和开始执行之间只有一个明确的同步竞态点;取消先获得状态锁时, +任务体保证不会执行。 + +### 运行中取消 + +任务开始后,`Cancel` 调用 `stop_source.request_stop()`。executor 不强制停止 +线程;closure 接收 `std::stop_token`,在文件枚举、索引和符号加载循环的 +安全边界调用 `stop_requested()` 并尽快返回。 + +如果运行中收到停止请求,即使 closure 已产生普通值,executor 仍将最终 +状态归类为 `kCancelled`,避免向 callback 报告成功。 + +### 完成与 callback + +executor 捕获任务异常并生成 `kFailed`,保留原始 `exception_ptr`。任务结果 +先写入状态,再调用 callback;callback 异常被捕获并记录,但不改写已经确定 +的任务结果。只有 callback 返回或异常被处理后,状态才标记为完全完成、从 +两个注册表注销并唤醒等待者。 + +callback 不得从自身调用 `Wait` 或 `WaitAll`;该约束写入接口注释和测试命名, +避免自等待死锁。 + +## 并发数与生命周期 + +传入并发数 0 或 `std::thread::hardware_concurrency()` 返回 0 时,实际 worker +数收敛到 1,再构造 `tf::Executor`。 + +`AsyncExecutor` 析构时调用 `WaitAll`。`WaitAll` 等待所有活跃状态完成,并再 +调用 Taskflow `wait_for_all()`,确保 callback 中提交的后续任务也已退出。 +析构期间不允许其他线程继续调用 `Submit`;这是对象生命周期的基本前置条件。 + +## 调用方迁移 + +所有 `scheduler::AsyncExecutor`、`scheduler::TaskHandle` 等引用迁移到 +`scheduler::async_executor::*`。所有 `Submit` closure 增加 +`std::stop_token` 参数,callback 改为接收 `const TaskResult&`。 + +工作区加载、系统库加载、文件索引和 workspace folder 变更任务将 stop token +传入长循环;循环在每个文件或目录边界检查停止请求。只执行一次且不可拆分的 +第三方调用在调用前后检查 token,不尝试中断其内部线程。 + +## 清理 + +- 删除公开的 `detail::ExecutionState` 与 `detail::ActiveEntry`。 +- 将 `ExecutorMetrics` 作为明确公共结果类型保留在 + `lsp::scheduler::async_executor`。 +- 删除未使用的 `ActiveEntry::callback`、`ActiveEntry::start_time` 和 + `kStatusLogInterval`。 +- 测试 Module 的入口同步放入自身命名空间,不再导出全局 `Run`。 + +## 测试设计 + +测试遵循 TDD,先在旧实现上观察失败,再实现新语义: + +- 无延迟任务完成后,handle 仍有效且可读取结果。 +- `Wait` 返回任务体与 callback 完成后的最终结果。 +- 同 ID 替换后,旧 handle 取消不影响新任务。 +- 排队任务被取消后,任务体从未执行。 +- 运行中任务观察 stop token 并协作退出。 +- 同 ID 两个活跃实例被计数为 2,`WaitAll` 等待两者。 +- 并发数 0 可以正常执行任务。 +- task 异常生成 `kFailed` 并保留异常。 +- callback 异常不会导致等待死锁,也不会改写任务结果。 +- 成功、取消、失败统计与最终状态一致。 + +迁移完成后运行 scheduler 测试、provider 测试和生产服务器构建;LSP 传输 +冒烟测试确认后台初始化和 workspace 操作没有回归。 + +## 完成条件 + +- 快速完成任务的 handle 不失效。 +- 任一 handle 只能取消自己的状态。 +- 已取消的排队 closure 不执行。 +- 长任务可以通过 stop token 提前退出。 +- `GetRunningTaskCount` 和 `WaitAll` 覆盖重复 ID 的所有实例。 +- 不存在 `WaitForTask`、公开执行状态或未使用调度字段。 +- 并发数始终至少为 1。 +- namespace、调用方和测试全部迁移完成。 +- 约定范围内构建与测试通过。 diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 40a0e38..075c016 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -52,8 +52,8 @@ ## Workflow State -phase: done -spec: docs/superpowers/specs/2026-07-12-text-coordinates-utf16-design.md +phase: planning +spec: docs/superpowers/specs/2026-07-12-async-executor-cancellation-design.md plan: docs/superpowers/plans/2026-07-12-text-coordinates-utf16.md executor: executing-plans constraints: karpathy-guidelines,.agents,AGENT_RULES