Wave 5 (9 parallel agents): - W1.1 atomic diag callback + DLL handle release on shutdown (lin) - W2.1 unify cross-DLL heap discipline (host->alloc/free/strdup) (chen) - W2.2 secure_zero api_key on shutdown for deepseek/anthropic (cao) - W3 CMake modernization: target-based cxx_std_20, dstalk_boost_config INTERFACE lib, root-level RUNTIME_OUTPUT_DIRECTORY (hu) - W4 GitHub Actions CI with dynamic Linux/Windows matrix (ma) - W5.1 SSE buffer_body to cut peak memory ~67% on 32K streams (zhou) - W6.1 LSP JSON-RPC frame parser hardened against header reordering (sun) - W7 smoke test: copy plugin DLLs post-build + Boost.JSON src.hpp fix for full 9-plugin load coverage (wang) - W8.1 README slimmed 398->92, Diataxis docs/ skeleton (deng) Wave 6 (6 parallel agents): - W9.1 docs/explanation: architecture + plugin-lifecycle (deng) - W9.3 log credential leak audit (0 vulns, audit trail in docs/explanation/security-logging.md) (cao) - W9.4 docs/reference/plugin-abi.md - 7-point ABI contract (lin) - W9.6 CLI /history command + status integration (zhao) - W9.8 plugin_loader fault tolerance: per-plugin failure no longer aborts dstalk_init (huang) - W9.10 host_api unit tests: tests/host_api_test.cpp, 8 cases (liu) CEO oversight (preexisting bugs fixed during Wave 5 verification): - lsp_plugin.cpp:449 forward decl mismatch (int vs void) - tools_plugin.cpp:109 missing forward decl Multi-agent collaboration framework: - agents/WORKFLOW.md: 6-stage protocol, two-tier governance, prompt template, technical constraints registry Build: cmake --build 0 error / 0 warning. Tests: 2/2 100% pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
9.6 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 —— 因为服务调用完成后插件栈帧
可能已销毁。
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.deepseek")的场景中避免冲突。
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 也不能抛异常
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 后指针可能悬垂——调用方应复制。
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 返回错误。
变更历史
| 日期 | 版本 | 变更 |
|---|---|---|
| 2026-05-27 | 1.0 | 初始版本。W9.4 交付。基于 DSTALK_API_VERSION=1 的当前实现。 |