Files
dstalk/docs/explanation/plugin-lifecycle.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

8.0 KiB
Raw Permalink Blame History

插件生命周期

本文属于: Explanation -- 解释插件从加载到卸载的完整生命周期和关键契约。 不涉及具体 API 签名细节,那属于 Reference (reference/api.md) 的职责。


总览

一个 dstalk 插件从磁盘上的 DLL 文件到进程中活跃的服务提供者,经历了四个阶段:

  加载 DLL    →    依赖解析    →    on_init    →    on_shutdown
(load_plugin)    (topo sort)      (初始化)         (逆序清理)

阶段 1DLL 加载

Host 调用 load_plugin(path),由 PluginLoader 执行:

  1. 加载动态库。Windows 用 LoadLibraryALinux 用 dlopen(..., RTLD_NOW | RTLD_LOCAL)。注意 Linux 端使用了 RTLD_LOCAL 而非 RTLD_GLOBAL,这意味着每个插件的符号默认对其他插件不可见——跨插件通信必须通过 Host API不能通过直接符号引用。

  2. 查找入口函数。Host 在 DLL 中搜索名为 dstalk_plugin_init 的符号,调用它获取 dstalk_plugin_info_t*

  3. 版本校验。Host 检查 info->api_version 是否等于 DSTALK_API_VERSION。不匹配则拒绝加载——这是防止 ABI 断裂的第一道防线。

  4. 存储元数据PluginInfo 被存入 plugins_ map此时 initialized = false,插件尚未初始化。

关键点: 加载阶段只做符号获取和版本校验,不调用 on_init。这意味着插件代码此时尚未运行,不持有任何资源。


阶段 2依赖解析拓扑排序

所有插件加载完毕后,initialize_all 在调用 on_init 之前先执行拓扑排序。

为什么需要排序? 如果 openai 插件依赖 httpconfig,那么 httpconfig 插件的 on_init 必须先于 openai 执行——否则 openai 在 on_init 中调用 query_service("http") 会得到 nullptr

算法Kahn 算法BFS 拓扑排序)。

  1. 构建 name → id 的映射表。
  2. 为每个插件计算入度in-degree = 被多少个其他插件依赖)。
  3. 从入度为 0 的节点(无依赖或依赖已满足的插件)开始,逐层输出。
  4. 最终检查:若输出节点数不等于总节点数,说明存在循环依赖,抛出异常阻止初始化。

依赖声明在 dstalk_plugin_info_t.dependencies 中,以 NULL 结尾的字符串数组。最多 DSTALK_MAX_DEPS (8) 个依赖。

// 示例openai 插件声明依赖 http 和 config
{ "http", "config", NULL }

依赖名称与目标插件的 info->name 字段匹配(如 "file_io" 不是 DLL 的文件名),因此依赖声明和插件命名必须一致。


阶段 3on_init 的契约

on_init 是插件获得生命信号的地方。Host 按拓扑排序的结果依次调用每个插件的 on_init,传入 dstalk_host_api_t*

契约条款

契约 1host_api 在 on_init 执行期间完全可用。

dstalk_host_api_t 是插件访问一切 Host 能力的唯一通道。它的所有字段(register_servicequery_servicelogallocfree 等)在 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 调用都用它
    // ...
}

契约 2query_service 查找依赖。 插件在 on_init 中查询它声明的每一个依赖。如果某个依赖不存在,on_init 应当返回非零值,告知 Host 初始化失败。

g_http = (dstalk_http_service_t*)host->query_service("http", 1);
if (!g_http) return -1;  // 依赖缺失,初始化失败

Host 收到非零返回值后,会跳过后续插件的初始化并报告警告。

契约 3register_service 注册自己的服务。 插件将自己的 vtable 注册到服务注册表后,其他依赖它的插件才能在后续的 on_init 中通过 query_service 找到它。

return host->register_service("ai_openai", 1, &g_service);

注册表内的 vtable 是原始指针,不拷贝。因此 vtable 指向的结构体必须是静态生命周期(全局变量或 static 局部变量)。

契约 4不要在 on_init 中做阻塞操作。 当前 Host 是单线程初始化,阻塞一个插件的 on_init 会阻塞整个启动流程。如果需要异步初始化(如连接远程服务),在 on_init 中仅做最基本的 vtable 注册,把长连接放到首次服务调用时再建立。

契约 5所有内存分配通过 host_api->alloc/free。 见下文"ABI 纪律"节。


阶段 4on_shutdown 的契约

Host 关闭时,按拓扑排序的逆序调用 on_shutdown——这保证了被依赖者后卸载。

加载顺序: config → http → openai
卸载顺序: openai → 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 可能链接不同的 CRTC 运行时库。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_initdstalk_plugin_load 未初始化 load_plugindstalk_plugin_init()
依赖排序 initialize_all 等待初始化 topological_sort() (Kahn)
初始化 Host 按序调用 运行中 on_init(host_api)
服务调用 任意插件/CLI 前端 运行中 query_service → vtable 调用
卸载 dstalk_plugin_unloaddstalk_shutdown 关闭中 on_shutdown()FreeLibrary/dlclose