# 插件生命周期 > **本文属于**: Explanation -- 解释插件从加载到卸载的完整生命周期和关键契约。 > 不涉及具体 API 签名细节,那属于 Reference (`reference/api.md`) 的职责。 --- ## 总览 一个 dstalk 插件从磁盘上的 DLL 文件到进程中活跃的服务提供者,经历了四个阶段: ``` 加载 DLL → 依赖解析 → on_init → on_shutdown (load_plugin) (topo sort) (初始化) (逆序清理) ``` --- ## 阶段 1:DLL 加载 Host 调用 `load_plugin(path)`,由 `PluginLoader` 执行: 1. **加载动态库**。Windows 用 `LoadLibraryA`;Linux 用 `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 插件依赖 `http` 和 `config`,那么 `http` 和 `config` 插件的 `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 的文件名),因此依赖声明和插件命名必须一致。 --- ## 阶段 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 调用都需要它。 ```c 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 初始化失败。 ```c 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` 找到它。 ```c 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 纪律"节。 --- ## 阶段 4:on_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,但防御性置空是好习惯。 ```c 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` | ```c // 正确:跨 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` |