# 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`,使用 `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 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: ")` 记录原因, 便于诊断。`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 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 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 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 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 的当前实现。 |