Files
dstalk/docs/reference/plugin-abi.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

21 KiB
Raw Blame History

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 中的 nameversiondescription 由插件的 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 /freehost.cpp:111-112),所以 "host 分配 → host 释放" 总是在同一个 CRT 堆内。


4. register_service 契约

4.1 调用时机

host->register_service(name, version, vtable) 仅可在 on_init 回调期间调用

原因:

  • 服务注册表 ServiceRegistrydstalk_init() 内创建,on_init 之前已存在。
  • 初始化顺序由拓扑排序保证:依赖方的 on_init 在被依赖方之后调用,因此被依赖方注册的服务 在依赖方调用时已可用。
  • on_shutdown 期间不应注册新服务(该阶段仅做清理)。

4.2 重复注册

同一 name 不可重复注册:第二次调用返回 -2service_registry.cpp:13)。插件应检查返回 值,在共享服务名(如 "ai_openai")的场景中避免冲突。

4.3 版本协商

register_serviceversion 参数声明了该 vtable 实现的版本。query_servicemin_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_initon_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 也不能抛异常

延伸: 本节仅覆盖 on_init / on_shutdown 两个生命周期回调。所有通过 C ABI 导出的函数 (包括 service vtable 中的函数指针)均适用同样规则,详见 §8 异常安全——穿越 ABI 边界


6. 回调线程安全

6.1 诊断回调 (diag_callback)

g_diag_callbackstd::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_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/unloadPluginLoader 无内部锁)。

6.3 服务注册表 (ServiceRegistry)

使用 std::shared_mutexregister/unregister 持写锁query 持读锁。并发安全。

6.4 配置 (ConfigStore)

使用 std::mutexget/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 语法

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_trimconfig_getai_chat 等)
  3. EventBus 中注册的 dstalk_event_handler_fn 回调
  4. 任何通过 host->register_service 注册的 vtable 函数

为什么 service vtable 函数也受约束: vtable 函数指针的类型签名为纯 Cint (*)(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_allocstd::out_of_rangestd::system_error 的操作

标准包裹模式:

static int service_function(/* 参数 */) {
    try {
        // 主逻辑:可以使用 std::string / std::vector 等
        std::vector<std::string> 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: <reason>") 记录原因, 便于诊断。void 返回类型的函数即使无法向调用方报告错误,也必须记录日志。

8.4 反例:未保护的 service vtable 函数

以下代码来自 context_plugin.cpptrim_impl, L114-226。该函数由 vtable 中的 context_trim 直接调用,底层使用 std::vector / std::string,无 try/catch

// 反例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<TrimMessage> 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 函数

// 正例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<TrimMessage> 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->strduphost->alloc 调用方负责用 host->free 释放。

// 返回由 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(): 仅当文档明确约束 "下次调用同函数前有效" 且无并发写入
// 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-73config_plugin.cpp:72-78get() 在锁内获取 std::string::c_str(),锁释放后返回:

// 反例:锁释放后 c_str() 指针悬垂
const char* ConfigStore::get(const char* key) const {
    if (!key) return nullptr;
    std::lock_guard<std::mutex> 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:72config_plugin.cpp:77)。

9.4 正例:用 host->strdup 返回安全副本

// 正例:在锁内用 host->strdup 复制,调用方负责释放
static char* config_get_safe(const char* key) {
    if (!key) return nullptr;

    std::lock_guard<std::mutex> 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 的当前实现。