Wave 8: tech-debt audits, core unit tests, CLI pipe input (W11.1-W11.7)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled

- W11.1 context_plugin audit (architect-huang): 3 findings on ABI exception
  safety, strdup null checks, dead g_max_tokens variable. Rating: B.
- W11.2 config audit (engineer-chen): identified 74-line TOML parser
  duplication between config_plugin and config_store, dual-store data
  isolation, dangling c_str() risk. Rating: C.
- W11.3 event_bus + service_registry unit tests (qa-liu): 12 cases total,
  ctest coverage 2 -> 4 targets, 100% pass.
- W11.4 CLI stdin pipe mode (engineer-zhao): isatty detection, single-shot
  inference path with exit codes 0/1/2/3.
- W11.6 scripts/refresh_status.py (engineer-li): 431-line generator that
  scans 16 profile.md + 5 group.md to regenerate STATUS.md.
- W11.7 destructive testing (qa-xu): 10 input scenarios PASS, found bin
  copy mismatch (BUG-1) plus 3 minor UX bugs for follow-up.

Verified: cmake build 0 error, ctest 4/4 pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-27 09:06:25 +08:00
parent 004a81db96
commit bb2e8c0220
14 changed files with 1122 additions and 18 deletions

View File

@@ -0,0 +1,164 @@
# W11.1 Context Plugin Audit
**Auditor**: 黄岭 (architect-huang)
**Date**: 2026-05-27
**File**: plugins/context/src/context_plugin.cpp (289 行)
**Wave Coverage**: 零 (从未被 Wave 流程审计)
---
## 1. 跨 DLL 堆合规性
状态: **合规**
逐行检查所有 malloc/free/strdup/new/delete 调用点:
| 调用类型 | 搜索结果 | 判定 |
|----------|----------|------|
| `malloc()` | 0 处 | -- |
| `free()` | 0 处 | -- |
| `strdup()` (裸) | 0 处 | -- |
| `new` (显式) | 0 处 | -- |
| `delete` (显式) | 0 处 | -- |
| `g_host->alloc()` | L135, L215 | 正确: host 堆分配 |
| `g_host->strdup()` | L138-141, L219-222 | 正确: host 堆分配 |
| `std::string` / `std::vector` | L90-127 等多处 | 插件内部使用, 不跨边界 |
**结论**: 所有跨 DLL 边界的分配均通过 `g_host->alloc()` / `g_host->strdup()`, 符合 §3.2 硬性规则。内部 C++ 类型 (std::string/std::vector) 仅用于 TrimMessage 局部数据, 不出 DLL 边界。W2.1 堆纪律已隐式合规, 无需迁移。
**注意**: L159/L168 使用 `std::fprintf(stderr, ...)`, 非内存操作, 无堆问题。
---
## 2. ABI 契约符合度 (plugin-abi.md §1-§7)
### §1 DSTALK_API_VERSION
- L280: `DSTALK_API_VERSION`
- 导出函数签名 `dstalk_plugin_info_t* dstalk_plugin_init(void)``dstalk_plugin_init_fn` typedef 匹配 ✅
### §2 内存所有权
- `g_info.name/version/description` 为静态字符串字面量, 生命周期覆盖 init_fn 调用 ✅ (§2.2)
- 输出数组通过 `g_host->alloc` + `g_host->strdup` 分配, 调用方负责 `g_host->free` ✅ (§2.3)
### §3 跨 DLL 堆
- 已在上节验证 ✅
### §4 register_service
-`on_init` 内调用 (L268) ✅ (§4.1)
- 版本号 1 ✅
### §5 on_init/on_shutdown
- 函数签名匹配: `int (*)(const dstalk_host_api_t*)` / `void (*)(void)`
- **违反 §5.3 (异常安全)**: `on_init` / `context_count_tokens` / `context_trim` / `context_set_max_tokens` 均定义为 C 函数指针, 但底层 C++ 实现 (`trim_impl` L114-226) 使用了 `std::vector` / `std::string`, 二者均可抛出 `std::bad_alloc`。无任何 `try/catch` 包裹。若 OOM, 异常穿越 C ABI 边界 → `std::terminate()`, 违反 §5.3 约束。
受影响函数: `context_trim``trim_impl` (L114), `on_init``g_host->log` (可变参数, 低风险)
### §6 线程安全
- 见第 4 节
### §7 依赖声明
- L281: `{"session", nullptr, ...}`, NULL 终止 ✅
- `on_init` 内通过 `host->query_service("session", 1)` 验证依赖可用 (L261-265) ✅
### 评级: 基本合规, 但因 §5.3 异常安全缺失降为 **B**
---
## 3. 内存/资源泄漏分析
### 3.1 服务注册
- `on_init` 注册 "context" 服务 (L268), 静态 vtable `g_context_service` 无动态分配。
- `on_shutdown` (L271-274) 未调用 `unregister_service`**但合规**: 按 §4.4, host 在插件卸载时自动调用 `ServiceRegistry::unregister_service`。无需手动清理。
### 3.2 事件订阅
- 无事件订阅, 无泄漏风险 ✅
### 3.3 trim_impl 内存分配路径
三条返回路径分析:
| 路径 | 位置 | 内存状态 |
|------|------|----------|
| 单条消息超限, 提前返回 | L172-174 | `*out=nullptr`, 无分配, 局部 vector RAII 清理 ✅ |
| 完整副本路径 (current ≤ max) | L133-144 | `alloc` + `strdup` x N, 全部返回给调用方 ✅ |
| 裁剪后路径 | L213-225 | `alloc` + `strdup` x N, 全部返回给调用方 ✅ |
**发现**: `g_host->strdup` (L138-141, L219-222) 返回值未被检查。若 strdup 在循环中途失败返回 nullptr:
- 前置已成功的 strdup 分配被**泄漏** (无记录, 无法逐个释放)
- nullptr 被静默存入 message field, 函数返回 0 (成功)
- 调用方解引用 nullptr 的 role/content → 崩溃
**结论**: 正常路径无泄漏, 但 OOM 路径存在资源泄漏 + 空指针隐患。
### 3.4 g_session 死引用
- L261-266: `query_service("session")` 获取并存入 `g_session`
- 全文搜索: `g_session` **从未被读取** (仅在 L272 `on_shutdown` 中置 null)
- 不造成泄漏 (无额外分配), 但 dead code 暗示设计与实现脱节
### 评级: **B** (正常路径干净, OOM 路径脆弱)
---
## 4. 并发安全分析
### 4.1 共享状态清单
| 变量 | 写入点 | 读取点 | 同步 |
|------|--------|--------|------|
| `g_host` | L258 (on_init), L273 (on_shutdown) | L135/215 (trim_impl → alloc), L138-141/219-222 (strdup) | **无** |
| `g_session` | L266 (on_init), L272 (on_shutdown) | 无读取 | **无** |
| `g_max_tokens` | L244 (set_max_tokens) | 无读取 | **无** |
### 4.2 潜在竞争
**Race A: on_shutdown vs. trim_impl 并发** (g_host)
- 线程 T1: `context_trim()``trim_impl()` 读取 `g_host` (非 null)
- 线程 T2: `on_shutdown()` 写入 `g_host = nullptr`
- T1 随后调用 `g_host->alloc()`**空指针解引用**
- 缓解因素: host 应保证 shutdown 前无 in-flight 服务调用。但 ABI 未显式保证, 属未定义行为边界。
**Race B: set_max_tokens vs. 未来的 getter** (g_max_tokens)
- 当前无读取点, 不构成实际竞争。但 `set_max_tokens` 作为公开 API, 未来添加 getter 时需 `std::atomic` 保护。当前为**潜在债务**。
### 评级: **C** (g_host 读写的无同步访问是数据竞争, 虽有隐式时序假设但不可依赖)
---
## 5. 最严重发现 (Top 3)
### 发现 1 — [严重] C++ 异常穿越 ABI 边界 (违反 §5.3)
- **行号**: L114-226 (trim_impl), L257-268 (on_init), L232-240 (vtable 函数)
- **问题**: `trim_impl` 使用 `std::vector`/`std::string` 可抛 `std::bad_alloc`, 但无 `try/catch`。异常通过 C 函数指针边界 → `std::terminate()` → 进程崩溃。违反 plugin-abi §5.3 硬性约束。
- **修复方向**: 在 `trim_impl` 体和 `on_init` 体加 `try { ... } catch (...) { return -1; }`, 或预分配 + nothrow 版本。
### 发现 2 — [高] strdup 返回值未检查, OOM 时静默失败 + 泄漏
- **行号**: L138-141 (完整副本路径), L219-222 (裁剪后路径)
- **问题**: 循环内连续 4 次 `g_host->strdup` 调用均未检查返回值。若某次返回 nullptr: (a) 前置成功的 strdup 分配泄漏 (无释放手段); (b) nullptr 存入 message field, 函数返回 0 (成功); (c) 调用方解引用 null role/content → 段错误。
- **修复方向**: 每个 strdup 后检查返回值, 失败时逆序释放已分配字段, 再 `g_host->free(*out)`, 然后返回 -1。
### 发现 3 — [中] context_set_max_tokens 是死 API (g_max_tokens 未被读取)
- **行号**: L21 (声明), L243-244 (写入)
- **问题**: `context_set_max_tokens()` 写入 `g_max_tokens`, 但全文无任何代码路径读取该变量。`trim_impl` 使用参数 `max_tokens` (调用方直接传入), 不依赖全局状态。因此 `set_max_tokens` 调用对行为零影响——调用方期待的限制永不生效。
- **修复方向**: 要么在 `context_trim` 中读取 `g_max_tokens` 作为默认值 (当调用方传 0 时), 要么删除该 API / 改为返回当前值。
---
## 6. 整体评级
| 维度 | 评级 |
|------|------|
| 跨 DLL 堆合规 | A (完全合规) |
| ABI 契约符合度 | B (核心合规, §5.3 异常安全缺失) |
| 内存/资源泄漏 | B (正常路径干净, OOM 脆弱) |
| 并发安全 | C (g_host 无同步访问, 隐式时序依赖) |
| **综合** | **B** |
**总评**: context_plugin 在堆纪律上意外地干净 (没有需要迁移的裸 malloc/free/strdup/new/delete), 说明编码者即使未读 plugin-abi 也遵循了正确模式。主要风险集中在异常安全 (可导致进程崩溃) 和 OOM 鲁棒性。无安全漏洞、无明确内存泄漏、无崩溃性逻辑错误。
---
## 7. 补充发现 (优先级低)
- **UTF-8 解码无越界保护** (L42-64, L96-104): 多字节序列 (2/3/4 字节) 的 `i += N` 假设后续字节有效, 若输入截断则读越界。对 token 估算影响极小 (仅计数值偏差), 非功能性 bug。
- **token 计数逻辑重复** (L34-68 vs L91-106): `count_tokens_one_message``count_tokens_trim` 有 ~90% 重复, 在 C 字符串和 std::string 上各实现一套。维护风险 (两端需同步修改), 但不影响正确性。
- **0xC0/0xC1 过短编码未识别** (L52, L100): UTF-8 标准中 0xC0/0xC1 是无效起始字节 (过短编码), 但仍计入 `other_chars`。仅影响 token 估算计数, 不影响功能。
- **on_shutdown 不释放 g_info.dependencies** (L281): dependencies 是静态字符串数组, 无需释放, 无问题。

View File

@@ -0,0 +1,108 @@
# W11.2 Config Plugin / ConfigStore 职责与跨 DLL 堆审计
**审计人**: 陈风 (engineer-chen)
**日期**: 2026-05-27
**审计范围**: `plugins/config/src/config_plugin.cpp` (146行) + `dstalk-core/src/config_store.cpp` (83行)
---
## 1. 职责划分
| 文件 | 职责 | 所在层 |
|------|------|--------|
| `config_store.cpp` (dstalk::ConfigStore) | Host 自身的键值配置存储。`dstalk_init` 加载初始配置,通过 `host->config_get/set` 供所有插件调用 | core |
| `config_plugin.cpp` (匿名 ConfigStore) | 注册 "config" 服务 (vtable `dstalk_config_service_t`),提供 `load_file` 运行时调用接口,供其他插件通过 `query_service` 发现 | plugin |
**结论**: 职责有交集但不完全重复——plugin 提供了 core 未暴露的 `load_file` 运行时接口。然而,两个 ConfigStore 的 TOML 解析逻辑74行**逐字符完全相同**,且两个 config 存储在运行时相互独立host 的 config 与 plugin 的 config 是不同的 map存在以下问题
- 同一 key 在两个 store 中可能具有不同值
- `host->config_get("plugin_dir")` 读的是 core store`query_service("config")->get("plugin_dir")` 读的是 plugin store
- 用户不知道该用哪个接口
**建议边界**: core 的 ConfigStore 作为唯一真相源config plugin 不再持有自己的 store改为将 `load_file` / `get` / `set` 委托给 `g_host->config_get` / `g_host->config_set``load_file` 则需要 core 侧新增 `config_load_file` 到 host API 或 config plugin 自行实现(仅保留解析逻辑,写入 host store
---
## 2. 跨 DLL 堆合规
| 检查项 | config_store.cpp | config_plugin.cpp |
|--------|-----------------|-------------------|
| 直接 `malloc`/`free` | 无 | 无 |
| 直接 `strdup` | 无 | 无 |
| `new`/`delete` 跨边界 | 无(仅内部 STL 容器) | 无 |
| `get()` 返回 `const char*` 的所有权 | 指向内部 `std::string`,调用方不得释放 | 同左 |
| `set()` 的值类型 | `const char*` 输入,拷贝到 `std::string` | 同左 |
**结论**: **无跨 DLL 堆违规**。两个文件均完全使用 STL 容器(`std::unordered_map<std::string, std::string>`)管理内存,所有分配/释放均在各自 DLL 的 CRT 堆内完成。返回的 `const char*` 指向内部 string buffer调用方只读不释放符合 ABI 契约 2.1。
但需注意:`config_plugin.cpp:77` 返回的 `c_str()` 指向 plugin DLL 内部的 `std::string`。调用方若持有该指针跨越 plugin unload将导致 use-after-free。这在当前设计中是低风险config plugin 通常不会被卸载),但建议在 ABI 文档中明确说明。
---
## 3. 线程安全
| 文件 | 锁机制 | 评估 |
|------|--------|------|
| config_store.cpp | `mutable std::mutex`get/set 均持锁 | get/set 单独调用安全 |
| config_plugin.cpp | 同上(匿名类内 `std::mutex` | 同左 |
**已知问题**:
- **Dangling pointer (config_store.cpp:72, config_plugin.cpp:77)**: `get()` 在锁内获取 `it->second.c_str()`,锁释放后返回。并发 `set()` 同一 key 会触发 `std::string` 重分配使外部持有的指针悬垂。ABI 文档 6.4 已记录此风险但代码未做防护(如返回 `std::string` 副本或用 `host->strdup` 分配)。
- **load_file 非原子 (config_store.cpp:57-60, config_plugin.cpp:63-66)**: `load_file` 逐行解析,每写入一个 key-value 就释放锁。并发 `get()` 可观察到新旧配置混合的状态。设计上这是合理的(避免持锁做磁盘 I/O但调用方需知晓。
- **ServiceRegistry 与 ConfigStore 之间无锁协调**: plugin 注册 "config" 服务时(`config_plugin.cpp:126`ServiceRegistry 的写锁和 ConfigStore 的 mutex 是独立的。这在当前无问题(注册只存 vtable 指针),但如果未来 config service 的注册/卸载与 store 生命周期联动,需注意锁序。
---
## 4. 服务暴露
`config_plugin.cpp:124-127`:
```cpp
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
return host->register_service("config", 1, &g_service);
}
```
- **正确**: 在 `on_init` 期间注册,符合 ABI 契约 4.1(仅此时可注册)
- **正确**: vtable 为 static 全局变量生命周期覆盖插件整个加载期ABI 4.4
- **正确**: service name "config" 与 `dstalk_services.h:64` 定义一致
- **注意**: 未检查 `register_service` 返回值。如果另一个插件也注册 "config",返回 -2 会被静默忽略
- **去重**: ServiceRegistry 已实现重复注册检测(`service_registry.cpp:12-14`),但插件自身不感知失败
---
## 5. 三个最严重发现
### 发现 1: 完整 TOML 解析器代码重复 (Critical)
- **config_plugin.cpp:16-90** 与 **config_store.cpp:10-83** 的解析逻辑完全相同
- **影响**: 任何 bug 修复需双份维护;已发现 load_file 不处理 section 名前后空格、不支持 inline table 等问题,需在两个文件中分别修复
- **修复方向**: config plugin 消除自己的 ConfigStore改为委托 host store通过 `g_host->config_get/set`),仅保留 `load_file` 且将解析结果写入 host store
### 发现 2: 双配置存储导致数据孤岛 (High)
- **位置**: `host.cpp:176` 创建 `dstalk::ConfigStore`host store`config_plugin.cpp:98` 创建匿名 `ConfigStore`plugin store
- **影响**: `host->config_get("key")``query_service("config")->get("key")` 返回不同数据,用户困惑
- **修复方向**: 合并为唯一 store建议保留 host 侧 `dstalk::ConfigStore`config plugin 只做服务注册包装)
### 发现 3: get() 返回悬垂指针 (High)
- **config_store.cpp:72** / **config_plugin.cpp:77**: `return it->second.c_str()` 在锁释放后,并发 set 同一 key 触发 realloc
- **影响**: 调用方持有的 `const char*` 可能指向已释放内存,导致 crash 或静默数据损坏
- **修复方向**: 选项 A — 返回值拷贝(调用方用 `host->strdup` 在锁内复制);选项 B — 文档明确规定调用方必须立即复制(当前 ABI spec 6.4 方向);选项 C — 用 `std::string` 作为返回值类型(需改 vtable 签名,涉及 API 版本 bump
---
## 6. 整体评级
**评级: C**
**理由**: 无跨 DLL 堆违规(本次审计核心关注点通过),线程安全基础正确但 get() 存在悬垂指针缺陷。最严重的问题是 74 行 TOML 解析器完全重复 + 双存储架构混乱,属于设计层面而非实现 bug但维护代价和用户困惑程度显著。建议优先解决发现 1 和 2合并 store发现 3 在合并时一并修复。
---
## 7. 附加建议
1. **config plugin 的 `on_init` 检查返回值** (config_plugin.cpp:126): `register_service` 返回 -2 时应有日志警告
2. **load_file 前清空已有数据**: 当前 `load_file` 不先 `data_.clear()`新文件与旧数据混合。如果这是预期行为merge 语义),应在注释中说明
3. **config_store.hpp 缺少 `load_file` 文档**: 未说明返回值约定0=成功, -1=文件不存在/解析失败)

View File

@@ -0,0 +1,53 @@
# W11.7 破坏性输入测试报告
**测试人**: 徐磊 (qa-xu)
**日期**: 2026-05-27
**被测二进制**: `build/dstalk-cli/dstalk-cli.exe` (MD5: 803ca2ea)
**源 Commit**: `004a81d` (Wave 7: W10.1-W10.4)
**测试环境**: Windows 11 Pro, 无 API key, 无 config.toml, 无 plugins/
## 测试结果汇总
| # | 场景 | 结果 | 退出码 | 备注 |
|---|------|------|--------|------|
| 1 | 启动+EOF (空pipe) | PASS | 0 | 直接进入 shutdown干净退出 |
| 2 | 空字符串回车 | PASS | 0 | fgets→len==0→continue 正确跳过 |
| 3 | 10000 字符超长行 | PASS | 0 | 截断警告正常触发,剩余字符 drain 正确 |
| 4 | 纯空格/纯Tab | PASS | 0 | 被当 AI promptAI 不可用报错不崩溃 |
| 5 | 未知 /command | PASS | 0 | "未知命令" 提示正常 |
| 6 | /status + /history 无session | PASS | 0 | /status 正常显示默认值;/history 报 "session service unavailable" |
| 7 | 中文+emoji prompt | PASS | 0 | UTF-8 无问题AI 不可用报错不崩溃 |
| 8 | ASCII 控制字符 \x01-\x08 | PASS | 0 | 被当 AI prompt 处理,无崩溃 |
| 9 | /quit 后重启 | PASS | 0,0 | 两次启动无残留状态 |
| 10 | 空 stdin (printf '') | PASS | 0 | fgets 立即返回 NULL干净退出 |
**结论: 10/10 全部 PASS零崩溃零 hang。**
## 发现的 Bug
### BUG-1 [CRITICAL] build/bin/dstalk-cli.exe 是损坏副本
- **MD5**: d8e8c92b (67,072 bytes) vs 正常 803ca2ea (66,048 bytes)
- **症状**: 所有命令(含 /help、/quit)被当 AI prompt 处理;任何输入退出码固定为 3错误信息是英文("AI or session service unavailable")而非正常的中文
- **推测**: CMake install/post-build 步骤拷贝了不同版本源码编译的产物,类似 PM-001 stale obj 模式
- **影响**: 任何人若按文档路径 `build/bin/dstalk.exe` 运行将得到完全不可用版本
### BUG-2 [MEDIUM] /clear 在 session 不可用时谎报成功
- `main.cpp:168-172`: `if (g_session) g_session->clear()` 后无条件 `printf("[OK] 会话已清空")`
- g_session==null 时也打印成功,误导用户
### BUG-3 [LOW] /context 在 session 不可用时静默无输出
- `main.cpp:175-185`: 仅 `if (g_session) {...}` 无 else 分支
- 用户输入 /context 后零反馈,不知所措
### BUG-4 [LOW] /file write 裸命令被当未知命令
- `strncmp(line, "/file write ", 12)` 要求尾部空格,`/file write` (无参) 匹配失败落入"未知命令"
- 应给用法提示而非未知命令
## 变更历史
| 日期 | 版本 | 变更 |
|------|------|------|
| 2026-05-27 | 1.0 | W11.7 破坏性测试10 场景全 PASS发现 4 个 bug (1 critical) |