Files
dstalk/docs/reference/plugin-abi.md
XiuChengWu 8faa02c3d5 W17: extract ai_common shared module + fix anthropic data race + brace bugs
- New plugins_upper/ai_common/ static library: shared PluginConfig, ToolCallAccum,
  StreamContext, secure_zero, extract_host_port, serialize_tool_calls, free_chat_result
- Refactored openai/anthropic plugins to use dstalk_ai:: namespace from ai_common
- Fixed anthropic g_config raw pointer → std::atomic (data race)
- Added SSE parse error counter with threshold abort (kMaxSseParseErrors=5)
- Fixed missing closing brace in both plugins' error-body catch block
- Updated test targets: ai_common include path + link, using namespace dstalk_ai
- plugin_loader_test: added stub_unreg + service_registry.cpp for unregister_service
- Includes pre-existing uncommitted changes from prior waves

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 16:58:25 +08:00

523 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# dstalk Plugin ABI 契约
> **面向**: 插件作者、host 维护者
> **性质**: 规范性文档。违反任何条目 = 未定义行为。
---
## 1. DSTALK_API_VERSION
```c
#define DSTALK_API_VERSION 1 // dstalk_host.h
```
**语义**: 主版本号。当且仅当 `dstalk_host_api_t` 的字段布局、`dstalk_plugin_info_t` 的结构、或任意
`*_service_t` vtable 的函数签名发生**不兼容变更**时 bump。
**不 bump 的情况**: 新增 event type 枚举值、新增日志级别、在 vtable 末尾追加函数指针(注意:
不能改变已有字段的偏移)。
**匹配规则**: `plugin::api_version` 必须**精确等于** `DSTALK_API_VERSION`。host 拒绝加载不匹配的
插件(`plugin_loader.cpp:68`),不提供后向兼容。这是硬断点——旧插件在新 host 上重新编译即可适配,
二进制兼容不做保证。
**版本协商被拒绝的理由**: 早期版本刻意保持简单。如果未来需要协商(如 min/max version range
通过 bump `DSTALK_API_VERSION` 到 2 并定义协商结构体即可。W1.x 阶段不需要。
---
## 2. 内存所有权契约
Core rule: **谁分配,谁释放。分配函数必须与释放函数配对。**
### 2.1 Host 分配的字符串
以下函数返回的 `const char*` / `char*` **由 host 拥有**,调用方不得释放:
| 来源 | 示例 | 生命周期 |
|------|------|----------|
| `host->config_get(key)` | 配置值字符串 | 随 config 条目存在,或下次 `config_set` 覆盖前有效 |
| `dstalk_chat_result_t.content` | AI 回复 | 必须在读取后立即复制;下一轮 `chat()` 调用可能覆盖 |
以下函数返回的 `char*` **由调用方释放**(用 `dstalk_free` / `host->free`
| 函数 | 释放方式 |
|------|----------|
| `dstalk_strdup(s)` / `host->strdup(s)` | `dstalk_free(ptr)` / `host->free(ptr)` |
| `dstalk_plugin_list(output_json)` | `dstalk_free(*output_json)` |
| LSP 便捷函数 (`dstalk_lsp_*`) 的 `char** output` 参数 | `dstalk_free(*output)` |
### 2.2 插件返回的字符串
`dstalk_plugin_info_t` 中的 `name``version``description` 由插件的 `dstalk_plugin_init()`
返回。host 在加载时读取并内部分配 `std::string` 副本(`plugin_loader.cpp:81-83`),随后不再
引用原始指针。因此这些字符串的**生命周期只需覆盖 `init_fn()` 调用期间**——可以是静态字面量、
栈上字符串、或插件内部分配的内存。
### 2.3 服务 vtable 的返回值
`dstalk_chat_result_t.content``.error``.tool_calls_json` 按结构体注释约定由 `dstalk_strdup`
分配,**调用方**(即查询该服务的插件)负责 `dstalk_free`
**反例**: 插件直接返回 `std::string::c_str()` 或栈上 buffer —— 因为服务调用完成后插件栈帧
可能已销毁。有关字符串返回值的完整规则见 **§9 字符串返回值生命周期**。
---
## 3. 跨 DLL 堆纪律
### 3.1 问题
Windows 上每个 DLL 拥有独立的 CRT 堆(取决于链接方式:/MD 共享 或 /MT 静态)。插件在其
CRT 中调用 `malloc` 得到的指针host 调用 `free` 时访问的是 host CRT 的堆——行为未定义。
Linux/macOS 通常共享 libc但静态链接或不同 libc 版本时同样可能 crash。
### 3.2 硬性规则
> **严禁插件直接调用 `malloc`/`free`/`strdup`/`new`/`delete` 处理 host 传入或传出的数据。**
正确做法:
| 场景 | 错误做法 | 正确做法 |
|------|----------|----------|
| 释放 host 给的字符串 | `free(host->config_get("key"))` | 不释放;只读后丢弃 |
| 分配传给 host 的缓冲区 | `malloc(256)` | `host->alloc(256)``dstalk_alloc(256)` |
| 释放 host 分配的内存 | `delete ptr` / `std::free(ptr)` | `host->free(ptr)``dstalk_free(ptr)` |
| 复制 host 给的字符串 | `strdup(host->config_get("k"))` | `host->strdup(...)``dstalk_strdup(...)` |
| 在插件 DLL 内分配/释放私有数据 | 可以用任何方式 | 只要不跨越 DLL 边界 |
### 3.3 设计理由
`host->alloc` / `host->free` / `host->strdup` 是通过函数指针调回 host DLL 的 `malloc`/`free`
保证分配和释放发生在**同一个堆**上。`g_host_api` 表的 `api_alloc`/`api_free` 直接就是 `malloc`
/`free``host.cpp:111-112`),所以 "host 分配 → host 释放" 总是在同一个 CRT 堆内。
---
## 4. register_service 契约
### 4.1 调用时机
`host->register_service(name, version, vtable)` **仅可在 `on_init` 回调期间调用**
原因:
- 服务注册表 `ServiceRegistry``dstalk_init()` 内创建,`on_init` 之前已存在。
- 初始化顺序由拓扑排序保证:依赖方的 `on_init` 在被依赖方之后调用,因此被依赖方注册的服务
在依赖方调用时已可用。
- `on_shutdown` 期间不应注册新服务(该阶段仅做清理)。
### 4.2 重复注册
同一 `name` 不可重复注册:第二次调用返回 `-2``service_registry.cpp:13`)。插件应检查返回
值,在共享服务名(如 `"ai_openai"`)的场景中避免冲突。
### 4.3 版本协商
`register_service``version` 参数声明了该 vtable 实现的版本。`query_service`
`min_version` 允许调用方声明最低需求版本。当前实现为整型比较:`registered_version >=
min_version` 即可。
**插件作者约定**: version 从 1 开始。vtable 新增函数指针(追加到末尾)→ bump version。
vtable 重排字段或删除函数 → 改 service name`"lsp"``"lsp2"`)。
### 4.4 vtable 生命周期
注册的 vtable 指针在 `ServiceRegistry` 中存储,随插件存在。插件卸载时,`ServiceRegistry::
unregister_service` 会被调用。**插件不得在卸载后继续持有该 vtable 指针**。
---
## 5. on_init / on_shutdown 契约
### 5.1 调用顺序保证
- **on_init**: 按拓扑顺序调用(依赖项先初始化)。由 `PluginLoader::topological_sort()`
使用 Kahn 算法计算(`plugin_loader.cpp:144-198`)。
- **on_shutdown**: 按拓扑顺序的**逆序**调用(依赖项后销毁)。如果拓扑排序因循环依赖失败,
降级为任意顺序(`plugin_loader.cpp:263-267`)。
- **增量加载**: `dstalk_plugin_load()` 只初始化新加载的插件(`initialize_pending`),已
初始化的插件不受影响。
### 5.2 返回值
`on_init` 返回 `0` = 成功;非零 = 失败。失败导致 `initialize_all` 返回 `-1`,整体初始化
流程中止。失败插件的 `on_shutdown` **不会被调用**`plugin.initialized` 保持 false
### 5.3 异常安全 (C++ ABI)
> **C++ 异常不得穿越 C ABI 边界。**
`on_init``on_shutdown` 定义为 C 函数指针:
```c
int (*on_init)(const dstalk_host_api_t* host);
void (*on_shutdown)(void);
```
调用方PluginLoader**不设置** `try/catch` 保护(`plugin_loader.cpp:212-214`)。
如果 `on_init` 由 C++ 实现且抛出异常,将导致 `std::terminate` / 未定义行为。
**防护规则**:
-`extern "C"` 声明实现
- 所有可能抛异常的 C++ 逻辑用 `try { ... } catch (...) { return -1; }` 包裹
- `on_shutdown` 同理,即使 void 也不能抛异常
> **延伸**: 本节仅覆盖 `on_init` / `on_shutdown` 两个生命周期回调。**所有**通过 C ABI 导出的函数
> (包括 service vtable 中的函数指针)均适用同样规则,详见 **§8 异常安全——穿越 ABI 边界**。
---
## 6. 回调线程安全
### 6.1 诊断回调 (diag_callback)
`g_diag_callback``std::atomic<dstalk_diag_cb>`,使用 `memory_order_acquire` / `release`
`host.cpp:28,54,305`)。**多线程同时调用 `dstalk_log` 安全**——每个线程独立读取原子指针,
无数据竞争。
### 6.2 事件总线 (EventBus)
`EventBus` 使用 `std::shared_mutex`
- `subscribe` / `unsubscribe` 持有 `unique_lock`
- `emit` 持有 `shared_lock`
**结论**: 多线程 subscribe/unsubscribe/emit 安全。但 emit 持有 shared_lock 期间直接调用
handler —— handler 内**不得调用 subscribe/unsubscribe**(会尝试 unique_lock 导致死锁)。
`on_event` 回调与 `on_init` / `on_shutdown` 之间无互斥保护,因此**不应在 `on_event` 回调
内调用 host API 执行插件的 load/unload**PluginLoader 无内部锁)。
### 6.3 服务注册表 (ServiceRegistry)
使用 `std::shared_mutex`register/unregister 持写锁query 持读锁。并发安全。
### 6.4 配置 (ConfigStore)
使用 `std::mutex`get/set 串行化。`config_get` 返回的指针指向内部 `std::string`;在并发
`config_set` 同一 key 后指针可能悬垂——调用方应复制。详细规则见 **§9 字符串返回值生命周期**。
### 6.5 Plugin Loader
`PluginLoader` **无内部互斥**`plugin_loader.hpp` 无 mutex 成员。load/unload 不应在
多线程中并发调用。这是 host 层的设计假设——仅 `dstalk_init` / `dstalk_shutdown` 和显式
CLI 命令使用。
---
## 7. 依赖声明 (dependencies)
### 7.1 语法
```c
const char* dependencies[DSTALK_MAX_DEPS]; // DSTALK_MAX_DEPS = 8
```
`NULL` 终止的字符串数组,每个元素为被依赖插件的 `name`
### 7.2 语义
- 被依赖项必须先于依赖方初始化(拓扑排序保证)
- 被依赖项必须后于依赖方销毁(逆序 shutdown
- 如果被依赖项不存在:初始化时不影响拓扑排序(仅计算已加载插件间的依赖关系),但插件的
`on_init` 自己应检查所需服务是否可用(通过 `host->query_service`
- 循环依赖:拓扑排序失败,`topological_sort()` 抛出 `std::runtime_error("Circular
dependency detected")`,被 `initialize_all` 捕获并返回 `-1`
- 最大 8 个依赖,超出部分截断(`plugin_loader.cpp:90`
### 7.3 最佳实践
`on_init` 内通过 `host->query_service("service_name", min_version)` 验证依赖可用。
不做假设。这也是一种隐式版本协商:如果 service version 不足,`on_init` 返回错误。
---
## 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 的当前实现。 |