- Introduced comprehensive unit tests for the OpenAI plugin, covering SSE parsing, sentinel matching, delta extraction, request building, and more. - Created a new markdown file detailing coding and naming conventions for the dstalk project, including guidelines for comments, naming rules, code organization, and memory management practices.
21 KiB
dstalk Plugin ABI 契约
面向: 插件作者、host 维护者 性质: 规范性文档。违反任何条目 = 未定义行为。
1. DSTALK_API_VERSION
#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 函数指针:
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_lockemit持有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 语法
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 调用的函数入口:
dstalk_plugin_info_t中的on_init/on_shutdown/on_event(已在 §5.3 覆盖)- 所有
*_service_tvtable 中的函数指针(如context_trim、config_get、ai_chat等) EventBus中注册的dstalk_event_handler_fn回调- 任何通过
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::wstringstd::vector/std::map/std::unordered_map等 STL 容器std::unique_ptr/std::shared_ptr/std::make_unique/std::make_sharedstd::stringstream/std::ifstream/std::ofstream- 任何可能抛出
std::bad_alloc、std::out_of_range、std::system_error的操作
标准包裹模式:
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++ 异常可穿越 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 函数
// 正例: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:
- 悬垂指针 (use-after-free): 指针指向的
std::string内部 buffer 在锁释放后 被并发set()的 realloc 回收,或函数返回后栈帧销毁。 - 跨堆释放 (heap corruption): 调用方尝试
free()插件 CRT 堆分配的指针, 或反之。
§2 已覆盖内存所有权归属;§3 已覆盖跨 DLL 堆纪律。本节是对字符串返回值生命周期的 专项强制条款,与 §2、§3 构成完整的指针契约体系。
9.2 规则:两种合法模式
模式 A:拥有权转移(推荐)
约定: 返回的 char* 由函数分配(通过 host->strdup 或 host->alloc),
调用方负责用 host->free 释放。
// 返回由 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(): 仅当文档明确约束 "下次调用同函数前有效" 且无并发写入
// 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_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
问题分析:
std::string内部 buffer 由std::unordered_map的值对象拥有。- 并发
set()同一 key 触发operator=→ 可能 realloc → 旧 buffer 被释放。 - 外部持有的
c_str()指针已悬垂,访问即未定义行为。
W11.2 审计已确认此问题存在于两个独立 ConfigStore 实现中
(config_store.cpp:72 和 config_plugin.cpp:77)。
9.4 正例:用 host->strdup 返回安全副本
// 正例:在锁内用 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) 释放
}
为什么这是安全的:
host->strdup调回 host CRT 堆,分配和未来的释放都在同一堆(符 §3)。- 锁内完成复制——即使 T2 随后
set()同一 key,新分配的内存与旧 buffer 无关。 - 调用方拥有返回指针的所有权——不再依赖 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 的当前实现。 |