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>
8.0 KiB
插件生命周期
本文属于: Explanation -- 解释插件从加载到卸载的完整生命周期和关键契约。 不涉及具体 API 签名细节,那属于 Reference (
reference/api.md) 的职责。
总览
一个 dstalk 插件从磁盘上的 DLL 文件到进程中活跃的服务提供者,经历了四个阶段:
加载 DLL → 依赖解析 → on_init → on_shutdown
(load_plugin) (topo sort) (初始化) (逆序清理)
阶段 1:DLL 加载
Host 调用 load_plugin(path),由 PluginLoader 执行:
-
加载动态库。Windows 用
LoadLibraryA;Linux 用dlopen(..., RTLD_NOW | RTLD_LOCAL)。注意 Linux 端使用了RTLD_LOCAL而非RTLD_GLOBAL,这意味着每个插件的符号默认对其他插件不可见——跨插件通信必须通过 Host API,不能通过直接符号引用。 -
查找入口函数。Host 在 DLL 中搜索名为
dstalk_plugin_init的符号,调用它获取dstalk_plugin_info_t*。 -
版本校验。Host 检查
info->api_version是否等于DSTALK_API_VERSION。不匹配则拒绝加载——这是防止 ABI 断裂的第一道防线。 -
存储元数据。
PluginInfo被存入plugins_map,此时initialized = false,插件尚未初始化。
关键点: 加载阶段只做符号获取和版本校验,不调用 on_init。这意味着插件代码此时尚未运行,不持有任何资源。
阶段 2:依赖解析(拓扑排序)
所有插件加载完毕后,initialize_all 在调用 on_init 之前先执行拓扑排序。
为什么需要排序? 如果 deepseek 插件依赖 http 和 config,那么 http 和 config 插件的 on_init 必须先于 deepseek 执行——否则 deepseek 在 on_init 中调用 query_service("http") 会得到 nullptr。
算法:Kahn 算法(BFS 拓扑排序)。
- 构建
name → id的映射表。 - 为每个插件计算入度(in-degree = 被多少个其他插件依赖)。
- 从入度为 0 的节点(无依赖或依赖已满足的插件)开始,逐层输出。
- 最终检查:若输出节点数不等于总节点数,说明存在循环依赖,抛出异常阻止初始化。
依赖声明在 dstalk_plugin_info_t.dependencies 中,以 NULL 结尾的字符串数组。最多 DSTALK_MAX_DEPS (8) 个依赖。
// 示例:deepseek 插件声明依赖 http 和 config
{ "http", "config", NULL }
依赖名称与目标插件的 info->name 字段匹配(如 "file-io" 不是 DLL 的文件名),因此依赖声明和插件命名必须一致。
阶段 3:on_init 的契约
on_init 是插件获得生命信号的地方。Host 按拓扑排序的结果依次调用每个插件的 on_init,传入 dstalk_host_api_t*。
契约条款
契约 1:host_api 在 on_init 执行期间完全可用。
dstalk_host_api_t 是插件访问一切 Host 能力的唯一通道。它的所有字段(register_service、query_service、log、alloc、free 等)在 on_init 调用时已经有效。插件应当将这个指针保存为全局变量——每次跨 API 调用都需要它。
static const dstalk_host_api_t* g_host = nullptr;
static int on_init(const dstalk_host_api_t* host) {
g_host = host; // 保存,此后的所有 API 调用都用它
// ...
}
契约 2:query_service 查找依赖。 插件在 on_init 中查询它声明的每一个依赖。如果某个依赖不存在,on_init 应当返回非零值,告知 Host 初始化失败。
g_http = (dstalk_http_service_t*)host->query_service("http", 1);
if (!g_http) return -1; // 依赖缺失,初始化失败
Host 收到非零返回值后,会跳过后续插件的初始化并报告警告。
契约 3:register_service 注册自己的服务。 插件将自己的 vtable 注册到服务注册表后,其他依赖它的插件才能在后续的 on_init 中通过 query_service 找到它。
return host->register_service("ai.deepseek", 1, &g_service);
注册表内的 vtable 是原始指针,不拷贝。因此 vtable 指向的结构体必须是静态生命周期(全局变量或 static 局部变量)。
契约 4:不要在 on_init 中做阻塞操作。 当前 Host 是单线程初始化,阻塞一个插件的 on_init 会阻塞整个启动流程。如果需要异步初始化(如连接远程服务),在 on_init 中仅做最基本的 vtable 注册,把长连接放到首次服务调用时再建立。
契约 5:所有内存分配通过 host_api->alloc/free。 见下文"ABI 纪律"节。
阶段 4:on_shutdown 的契约
Host 关闭时,按拓扑排序的逆序调用 on_shutdown——这保证了被依赖者后卸载。
加载顺序: config → http → deepseek
卸载顺序: deepseek → http → config
注意:如果拓扑排序失败(如循环依赖),shutdown_all 会退化为任意顺序,仅保证所有插件的 on_shutdown 都被调用、所有 DLL 句柄都被释放。
契约条款
契约 1:逆序卸载,释放持久的服务引用。 在 on_init 中保存的服务指针(如 g_http)应在 on_shutdown 中置为 nullptr。这防止插件在卸载后仍持有悬垂指针——虽然当前实现是在 on_shutdown 之后才释放 DLL,但防御性置空是好习惯。
static void on_shutdown() {
g_http = nullptr;
g_config = nullptr;
g_host = nullptr;
}
契约 2:不能跨 DLL 堆边界释放。 在插件 A 的 on_shutdown 中,如果还持有插件 B 分配的内存,不能简单地调用 g_host->free——这会触发跨堆释放的未定义行为。正确做法是调用提供方的专属释放函数(如 AI 服务的 free_result),或让提供方在 on_shutdown 中清理自己分配的资源。
契约 3:删除已注册的服务。 当前 ServiceRegistry 不自动清理。如果一个服务 remove 了对应的插件,应该在卸载期间调用 unregister_service。当前实现未强制这一点,但关闭过程会销毁整个 ServiceRegistry,所以注销是可选的(若不注销,重启 host 时不会残留)。
ABI 纪律:为什么 Host 提供 alloc / free
这是插件架构中最容易被忽视但最容易出错的问题。
问题:在 Windows 上,每个 DLL 可能链接不同的 CRT(C 运行时库)。DLL A 用 MSVC 2022 的 malloc 分配内存,DLL B 用 MSVC 2019 的 free 释放——两个 free 管理不同的堆,导致崩溃或堆损坏。
解决:dstalk 要求所有跨 DLL 边界的内存操作使用 Host 提供的统一分配/释放函数:
| 操作 | 用这个 |
|---|---|
| 分配内存 | host_api->alloc(size) → 调用 host 启动时链接的 malloc |
| 释放内存 | host_api->free(ptr) → 调用 host 启动时链接的 free |
| 复制字符串 | host_api->strdup(s) → 用 host 的 malloc + memcpy |
// 正确:跨 DLL 返回的字符串用 host 分配
r.content = g_host->strdup(ctx.accumulated.c_str());
// 正确:调用方释放跨 DLL 返回的数据用 host 释放
g_host->free((void*)result->content);
// 错误:用本地 malloc 分配跨 DLL 边界的数据
// r.content = strdup(...); // 消费者的 free() 和此 strdup 的 malloc() 可能不同堆!
规则记忆: 谁分配,谁负责提供释放手段。dstalk 的选择是让 Host 统一分配——所有 alloc/free 调用走同一个 CRT 的堆。
生命周期速查
| 阶段 | 谁触发 | 插件状态 | 关键函数 |
|---|---|---|---|
| DLL 加载 | dstalk_init 或 dstalk_plugin_load |
未初始化 | load_plugin → dstalk_plugin_init() |
| 依赖排序 | initialize_all |
等待初始化 | topological_sort() (Kahn) |
| 初始化 | Host 按序调用 | 运行中 | on_init(host_api) |
| 服务调用 | 任意插件/CLI 前端 | 运行中 | query_service → vtable 调用 |
| 卸载 | dstalk_plugin_unload 或 dstalk_shutdown |
关闭中 | on_shutdown() → FreeLibrary/dlclose |