# 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 是静态字符串数组, 无需释放, 无问题。