Wave 9: fix audit findings, harden ABI, deduplicate config (W12.1-W12.6)
- W12.1 context_plugin (engineer-zhou): wrap C ABI surface in try/catch, add OOM-safe strdup_message_fields helper, make g_max_tokens drive message-count trim (option A). - W12.2 config refactor (architect-lin): introduce plugins/config/include/toml_parse.h to eliminate 74-line parser duplication; config_plugin delegates to host->config_get/set, collapsing the dual-store data island; ConfigStore::get() now copies via thread_local std::string to remove c_str() dangling under concurrent set(). Zero ABI changes. - W12.3 CLI command parsing (engineer-zhao): guard /clear and /context on missing session service; refactor /file dispatch so bare /file write hits usage instead of unknown-command. - W12.4 build path unification (devops-hu): set per-target RUNTIME_OUTPUT_DIRECTORY on dstalk-cli; remove stale build/dstalk-cli/dstalk-cli.exe so build/bin/ is the sole binary. - W12.5 STATUS.md auto-refresh (engineer-li): run W11.6 script to regenerate STATUS from live profile/group data. - W12.6 plugin-abi.md (writer-deng): add §8 exception safety across ABI boundary and §9 string return lifetime; reference real audit-found violations as anti-examples. Verified: cmake build 0 error 0 warning, 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:
@@ -60,7 +60,7 @@ Core rule: **谁分配,谁释放。分配函数必须与释放函数配对。*
|
||||
分配,**调用方**(即查询该服务的插件)负责 `dstalk_free`。
|
||||
|
||||
**反例**: 插件直接返回 `std::string::c_str()` 或栈上 buffer —— 因为服务调用完成后插件栈帧
|
||||
可能已销毁。
|
||||
可能已销毁。有关字符串返回值的完整规则见 **§9 字符串返回值生命周期**。
|
||||
|
||||
---
|
||||
|
||||
@@ -163,6 +163,9 @@ void (*on_shutdown)(void);
|
||||
- 所有可能抛异常的 C++ 逻辑用 `try { ... } catch (...) { return -1; }` 包裹
|
||||
- `on_shutdown` 同理,即使 void 也不能抛异常
|
||||
|
||||
> **延伸**: 本节仅覆盖 `on_init` / `on_shutdown` 两个生命周期回调。**所有**通过 C ABI 导出的函数
|
||||
> (包括 service vtable 中的函数指针)均适用同样规则,详见 **§8 异常安全——穿越 ABI 边界**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 回调线程安全
|
||||
@@ -192,7 +195,7 @@ handler —— handler 内**不得调用 subscribe/unsubscribe**(会尝试 uni
|
||||
### 6.4 配置 (ConfigStore)
|
||||
|
||||
使用 `std::mutex`:get/set 串行化。`config_get` 返回的指针指向内部 `std::string`;在并发
|
||||
`config_set` 同一 key 后指针可能悬垂——调用方应复制。
|
||||
`config_set` 同一 key 后指针可能悬垂——调用方应复制。详细规则见 **§9 字符串返回值生命周期**。
|
||||
|
||||
### 6.5 Plugin Loader
|
||||
|
||||
@@ -229,8 +232,291 @@ const char* dependencies[DSTALK_MAX_DEPS]; // DSTALK_MAX_DEPS = 8
|
||||
|
||||
---
|
||||
|
||||
## 8. 异常安全——穿越 ABI 边界
|
||||
|
||||
> **强制规则**: 所有导出给 host 的 C 函数(包括 `on_init` / `on_shutdown` 以及 service vtable
|
||||
> 中的每一个函数指针)**严禁**让 C++ 异常穿越函数边界。
|
||||
|
||||
### 8.1 适用范围
|
||||
|
||||
本规则覆盖以下所有通过 C ABI 调用的函数入口:
|
||||
|
||||
1. `dstalk_plugin_info_t` 中的 `on_init` / `on_shutdown` / `on_event`(已在 §5.3 覆盖)
|
||||
2. 所有 `*_service_t` vtable 中的函数指针(如 `context_trim`、`config_get`、
|
||||
`ai_chat` 等)
|
||||
3. `EventBus` 中注册的 `dstalk_event_handler_fn` 回调
|
||||
4. 任何通过 `host->register_service` 注册的 vtable 函数
|
||||
|
||||
**为什么 service vtable 函数也受约束**: vtable 函数指针的类型签名为纯 C(如
|
||||
`int (*)(const dstalk_message_t*, int, dstalk_message_t**, int*, size_t)`),
|
||||
调用方 `host` / 其他插件通过该指针直接调用,调用路径上无 `try/catch` 保护。
|
||||
C++ 异常在此路径上传播 → `std::terminate()` → 进程崩溃。
|
||||
|
||||
### 8.2 实施要求
|
||||
|
||||
**每个**使用以下 C++ 类型的函数外层,必须包裹异常保护:
|
||||
|
||||
- `std::string` / `std::wstring`
|
||||
- `std::vector` / `std::map` / `std::unordered_map` 等 STL 容器
|
||||
- `std::unique_ptr` / `std::shared_ptr` / `std::make_unique` / `std::make_shared`
|
||||
- `std::stringstream` / `std::ifstream` / `std::ofstream`
|
||||
- 任何可能抛出 `std::bad_alloc`、`std::out_of_range`、`std::system_error` 的操作
|
||||
|
||||
**标准包裹模式**:
|
||||
|
||||
```c
|
||||
static int service_function(/* 参数 */) {
|
||||
try {
|
||||
// 主逻辑:可以使用 std::string / std::vector 等
|
||||
std::vector<std::string> items;
|
||||
// ...
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
g_host->log(DSTALK_LOG_ERROR, "service_function: %s", e.what());
|
||||
return -1;
|
||||
} catch (...) {
|
||||
g_host->log(DSTALK_LOG_ERROR, "service_function: unknown exception");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 错误返回约定
|
||||
|
||||
| 返回类型 | 错误值 | 示例函数 |
|
||||
|----------|--------|----------|
|
||||
| `int` | 非零(通常 `-1`) | `on_init`, `context_trim`, `config_set` |
|
||||
| `const char*` / `char*` | `nullptr` | `config_get`, `strdup` |
|
||||
| `bool` | `false` | (当前无 bool 返回值的 vtable 函数) |
|
||||
| `size_t` | `0` | `context_count_tokens` |
|
||||
| `void` | 无返回值;仅记日志,禁止抛异常 | `on_shutdown`, `context_set_max_tokens` |
|
||||
|
||||
失败时推荐调用 `host->log(DSTALK_LOG_ERROR, "function_name: <reason>")` 记录原因,
|
||||
便于诊断。`void` 返回类型的函数即使无法向调用方报告错误,也必须记录日志。
|
||||
|
||||
### 8.4 反例:未保护的 service vtable 函数
|
||||
|
||||
以下代码来自 `context_plugin.cpp`(`trim_impl`, L114-226)。该函数由 vtable 中的
|
||||
`context_trim` 直接调用,底层使用 `std::vector` / `std::string`,无 `try/catch`:
|
||||
|
||||
```c
|
||||
// 反例:C++ 异常可穿越 ABI 边界 → std::terminate()
|
||||
static int trim_impl(const dstalk_message_t* in, int in_count,
|
||||
dstalk_message_t** out, int* out_count,
|
||||
size_t max_tokens) {
|
||||
// 无 try/catch 包裹!
|
||||
|
||||
// ▼ std::vector::reserve / push_back 可抛 std::bad_alloc
|
||||
std::vector<TrimMessage> messages;
|
||||
messages.reserve(in_count);
|
||||
for (int i = 0; i < in_count; ++i) {
|
||||
TrimMessage tm;
|
||||
tm.role = in[i].role; // std::string::operator= → bad_alloc
|
||||
tm.content = in[i].content; // 同上
|
||||
// ...
|
||||
messages.push_back(std::move(tm)); // push_back → bad_alloc
|
||||
}
|
||||
|
||||
// ▼ count_tokens_trim_vec 内对每个元素调用 count_tokens_trim,
|
||||
// 后者访问 std::string::operator[] → 不抛异常(已 bounds-checked)
|
||||
// 但 vector 迭代器可能被异常干扰
|
||||
size_t current = count_tokens_trim_vec(messages);
|
||||
|
||||
// ... 更多 std::vector 操作 ...
|
||||
|
||||
return 0; // ← 若以上任何操作抛异常,永远不会到达这里
|
||||
// 异常沿 C 函数指针返回 host,触发 std::terminate()
|
||||
}
|
||||
|
||||
// vtable 绑定——通过 C 函数指针直接暴露
|
||||
static int context_trim(const dstalk_message_t* in, int in_count,
|
||||
dstalk_message_t** out, int* out_count,
|
||||
size_t max_tokens) {
|
||||
return trim_impl(in, in_count, out, out_count, max_tokens);
|
||||
}
|
||||
```
|
||||
|
||||
**违反代价**: OOM 或任何 STL 异常发生时,进程直接 `std::terminate()`,无任何恢复机会。
|
||||
|
||||
### 8.5 正例:异常安全的 service 函数
|
||||
|
||||
```c
|
||||
// 正例:try/catch 包裹所有 C++ 操作,异常转换为错误码
|
||||
static int trim_impl_safe(const dstalk_message_t* in, int in_count,
|
||||
dstalk_message_t** out, int* out_count,
|
||||
size_t max_tokens) {
|
||||
try {
|
||||
if (!in || in_count <= 0 || !out || !out_count) return -1;
|
||||
|
||||
std::vector<TrimMessage> messages;
|
||||
messages.reserve(in_count);
|
||||
for (int i = 0; i < in_count; ++i) {
|
||||
TrimMessage tm;
|
||||
if (in[i].role) tm.role = in[i].role;
|
||||
if (in[i].content) tm.content = in[i].content;
|
||||
if (in[i].tool_call_id) tm.tool_call_id = in[i].tool_call_id;
|
||||
if (in[i].tool_calls_json) tm.tool_calls_json = in[i].tool_calls_json;
|
||||
messages.push_back(std::move(tm));
|
||||
}
|
||||
|
||||
// ... 裁剪逻辑 ...
|
||||
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
const dstalk_host_api_t* host = g_host; // 原子读或以其他安全方式获取
|
||||
if (host) {
|
||||
host->log(DSTALK_LOG_ERROR, "trim_impl: %s", e.what());
|
||||
}
|
||||
return -1;
|
||||
} catch (...) {
|
||||
if (g_host) {
|
||||
g_host->log(DSTALK_LOG_ERROR, "trim_impl: unknown exception");
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- `try/catch` 覆盖整个函数体——所有 STL 操作均在保护范围内
|
||||
- 两个 `catch` 子句:先捕获 `std::exception`(可取 `what()` 消息),再兜底 `...`
|
||||
- 日志中标注函数名,便于问题定位
|
||||
- 返回值转换为 `-1`,调用方可安全处理错误
|
||||
|
||||
---
|
||||
|
||||
## 9. 字符串返回值生命周期
|
||||
|
||||
> **强制规则**: 通过 ABI 返回的 `const char*` 必须满足以下二选一,禁止任何其他模式。
|
||||
|
||||
### 9.1 问题根源
|
||||
|
||||
C ABI 只能传递裸指针 `const char*`。指针指向的内存在何时释放、由谁释放,没有
|
||||
类型系统保障。错误的生命周期管理导致两大类 bug:
|
||||
|
||||
1. **悬垂指针 (use-after-free)**: 指针指向的 `std::string` 内部 buffer 在锁释放后
|
||||
被并发 `set()` 的 realloc 回收,或函数返回后栈帧销毁。
|
||||
2. **跨堆释放 (heap corruption)**: 调用方尝试 `free()` 插件 CRT 堆分配的指针,
|
||||
或反之。
|
||||
|
||||
§2 已覆盖内存所有权归属;§3 已覆盖跨 DLL 堆纪律。本节是对字符串返回值**生命周期**的
|
||||
专项强制条款,与 §2、§3 构成完整的指针契约体系。
|
||||
|
||||
### 9.2 规则:两种合法模式
|
||||
|
||||
#### 模式 A:拥有权转移(推荐)
|
||||
|
||||
**约定**: 返回的 `char*` 由函数分配(通过 `host->strdup` 或 `host->alloc`),
|
||||
**调用方**负责用 `host->free` 释放。
|
||||
|
||||
```c
|
||||
// 返回由 host 堆分配、调用方负责释放的字符串
|
||||
static char* service_get_owned_value(const char* key) {
|
||||
const char* raw = g_host->config_get(key);
|
||||
if (!raw) return nullptr;
|
||||
return g_host->strdup(raw); // 在 host 堆上复制,调用方 host->free 释放
|
||||
}
|
||||
```
|
||||
|
||||
**适用场景**: 需要将数据传出函数作用域、调用方需持有的情况。service vtable 函数返回
|
||||
动态内容时应使用此模式。
|
||||
|
||||
**文档要求**: 函数注释必须明确声明 "caller must free with `host->free`" 或等效说明。
|
||||
|
||||
#### 模式 B:静态 / 全局生命周期
|
||||
|
||||
**约定**: 返回的 `const char*` 指向编译期确定的内存,调用方**不得**释放。
|
||||
|
||||
允许的来源:
|
||||
- 字符串字面量: `return "hello";`
|
||||
- `static` 字符串数组: `static const char buf[] = "value"; return buf;`
|
||||
- 全局变量的 `c_str()`: **仅当**文档明确约束 "下次调用同函数前有效" 且无并发写入
|
||||
|
||||
```c
|
||||
// OK: 字符串字面量——程序生命周期内有效
|
||||
static const char* plugin_get_name(void) {
|
||||
return "context";
|
||||
}
|
||||
|
||||
// OK: static buffer——程序生命周期内有效
|
||||
static const char* plugin_get_version(void) {
|
||||
static const char version[] = "1.0.0";
|
||||
return version;
|
||||
}
|
||||
```
|
||||
|
||||
**不推荐的模式**: `static thread_local std::string` 虽然技术上满足 "下次调用前有效",
|
||||
但语义晦涩,调用方容易误用。仅在性能热点且调用方可证明不会跨调用持有指针时使用。
|
||||
|
||||
### 9.3 反例:锁外返回 `c_str()` (悬垂指针)
|
||||
|
||||
以下代码来自 `config_store.cpp:66-73` 和 `config_plugin.cpp:72-78`。
|
||||
`get()` 在锁内获取 `std::string::c_str()`,锁释放后返回:
|
||||
|
||||
```c
|
||||
// 反例:锁释放后 c_str() 指针悬垂
|
||||
const char* ConfigStore::get(const char* key) const {
|
||||
if (!key) return nullptr;
|
||||
std::lock_guard<std::mutex> lock(mutex_); // 获取锁
|
||||
auto it = data_.find(key);
|
||||
if (it == data_.end()) return nullptr;
|
||||
return it->second.c_str(); // 获取内部指针
|
||||
} // ← lock 在此释放。指针仍指向 map 内 string 的 buffer。
|
||||
|
||||
// 并发场景:
|
||||
// T1: const char* v = store.get("api_key"); // 得到指针 p → "sk-abc123"
|
||||
// T2: store.set("api_key", "sk-very-long-new-key-..."); // realloc! p 悬垂
|
||||
// T1: printf("%s\n", v); // ← 可能读取已释放内存、乱码或 crash
|
||||
```
|
||||
|
||||
**问题分析**:
|
||||
1. `std::string` 内部 buffer 由 `std::unordered_map` 的值对象拥有。
|
||||
2. 并发 `set()` 同一 key 触发 `operator=` → 可能 realloc → 旧 buffer 被释放。
|
||||
3. 外部持有的 `c_str()` 指针已悬垂,访问即未定义行为。
|
||||
|
||||
W11.2 审计已确认此问题存在于两个独立 ConfigStore 实现中
|
||||
(`config_store.cpp:72` 和 `config_plugin.cpp:77`)。
|
||||
|
||||
### 9.4 正例:用 `host->strdup` 返回安全副本
|
||||
|
||||
```c
|
||||
// 正例:在锁内用 host->strdup 复制,调用方负责释放
|
||||
static char* config_get_safe(const char* key) {
|
||||
if (!key) return nullptr;
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto it = data_.find(key);
|
||||
if (it == data_.end()) return nullptr;
|
||||
|
||||
// ★ 在锁内完成复制——指针从"内部 buffer"变为"独立分配的内存"
|
||||
return g_host->strdup(it->second.c_str());
|
||||
// 调用方用 host->free(ptr) 释放
|
||||
}
|
||||
```
|
||||
|
||||
**为什么这是安全的**:
|
||||
1. `host->strdup` 调回 host CRT 堆,分配和未来的释放都在同一堆(符 §3)。
|
||||
2. 锁内完成复制——即使 T2 随后 `set()` 同一 key,新分配的内存与旧 buffer 无关。
|
||||
3. 调用方拥有返回指针的所有权——不再依赖 map 内部状态。
|
||||
|
||||
**权衡**: 每次 `get()` 多一次堆分配。对于高频调用的 key(如配置读取),调用方应在
|
||||
获取后立即消费并释放;对于需长期持有的指针,这是唯一安全的方式。
|
||||
|
||||
### 9.5 违规代价
|
||||
|
||||
| 违规模式 | 后果 |
|
||||
|----------|------|
|
||||
| 返回锁外 `c_str()` | 并发 set 后悬垂指针 → crash 或静默数据损坏 |
|
||||
| 返回栈上 buffer 指针 | 函数返回后栈帧销毁 → use-after-free |
|
||||
| 返回 `std::string{...}.c_str()` | 临时对象在语句结束时析构 → 立即悬垂 |
|
||||
| 调用方 `free()` 模式 B 的指针 | 跨堆释放 → heap corruption / crash |
|
||||
| 调用方不释放模式 A 的指针 | 内存泄漏 |
|
||||
|
||||
---
|
||||
|
||||
## 变更历史
|
||||
|
||||
| 日期 | 版本 | 变更 |
|
||||
|------|------|------|
|
||||
| 2026-05-27 | 1.1 | W12.6 追加 §8 异常安全 (涵盖 service vtable 函数) 和 §9 字符串返回值生命周期;§2.3 / §5.3 / §6.4 添加交叉引用 |
|
||||
| 2026-05-27 | 1.0 | 初始版本。W9.4 交付。基于 DSTALK_API_VERSION=1 的当前实现。 |
|
||||
|
||||
Reference in New Issue
Block a user