W20: Tool Calling 闭环 + Stream+Tools + 回归测试 + session auto-save + ASan CI (W20.1-W20.6)
- W20.1: CLI tool_calls→execute→result→re-call 循环(5轮上限) - W20.2: deepseek 流式 tool_calls 增量解析(configure 缓存,无 ABI break) - W20.3: plugin_loader 回归测试 5 块 32 断言(路径/原子性/mock 日志) - W20.4: plugin_loader ABI 契约校验(name/version/on_init 字段验证) - W20.5: ASan/UBSan CMake preset + CI sanitizer job(PR-only Linux) - W20.6: session auto-save(on_shutdown 写 %APPDATA%/dstalk/session.json) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
64
.github/workflows/ci.yml
vendored
64
.github/workflows/ci.yml
vendored
@@ -160,3 +160,67 @@ jobs:
|
||||
echo "|----------|----------|-----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| ${{ matrix.os }} | ${{ (runner.os == 'Linux' && 'clang-18') || 'clang-cl' }} | ${DURATION}s |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "CI build: ${{ matrix.os }} / ${{ (runner.os == 'Linux' && 'clang-18') || 'clang-cl' }} wall time ${DURATION}s"
|
||||
|
||||
# ── Sanitizer (PR-only, Linux clang-18, no ccache) ───────
|
||||
sanitize:
|
||||
name: Sanitizer (ASan+UBSan) / ubuntu-24.04
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
# ── 1. 源码检出 ──────────────────────────────────────
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
# ── 2. 工具链 (clang-18) ─────────────────────────────
|
||||
- name: Install toolchain (Ubuntu)
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq clang-18 ninja-build
|
||||
echo "CC=clang-18" >> $GITHUB_ENV
|
||||
echo "CXX=clang++-18" >> $GITHUB_ENV
|
||||
|
||||
# ── 3. Python + Conan ─────────────────────────────────
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Conan
|
||||
run: pip install conan
|
||||
|
||||
# ── 4. Conan 依赖缓存 ─────────────────────────────────
|
||||
- name: Cache Conan
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.conan2
|
||||
~/.conan2/p
|
||||
key: ${{ runner.os }}-conan-Release-${{ hashFiles('deps/conanfile.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-conan-Release-
|
||||
${{ runner.os }}-conan-
|
||||
|
||||
# ── 5. Conan 依赖安装 ─────────────────────────────────
|
||||
- name: Install Conan dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
conan profile detect --force
|
||||
conan install deps --build=missing -s build_type=Release
|
||||
|
||||
# ── 6. CMake 配置 ─────────────────────────────────────
|
||||
- name: Configure CMake (Sanitizer)
|
||||
shell: bash
|
||||
run: cmake --preset ci-sanitize
|
||||
|
||||
# ── 7. 构建 ───────────────────────────────────────────
|
||||
- name: Build (Sanitizer)
|
||||
shell: bash
|
||||
run: cmake --build --preset ci-sanitize
|
||||
|
||||
# ── 8. 测试 ──────────────────────────────────────────
|
||||
- name: Test (Sanitizer)
|
||||
shell: bash
|
||||
run: ctest --preset ci-sanitize --output-on-failure
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
"value": "x64",
|
||||
"strategy": "external"
|
||||
},
|
||||
"toolchainFile": "generators\\conan_toolchain.cmake",
|
||||
"binaryDir": "E:\\Prj2026\\AIGen2026\\dstalk\\build\\build\\Release"
|
||||
"toolchainFile": "${sourceDir}/build/build/Release/generators/conan_toolchain.cmake",
|
||||
"binaryDir": "${sourceDir}/build/build/Release"
|
||||
},
|
||||
{
|
||||
"name": "ci-release",
|
||||
@@ -42,6 +42,18 @@
|
||||
},
|
||||
"toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake",
|
||||
"binaryDir": "${sourceDir}/build/ci"
|
||||
},
|
||||
{
|
||||
"name": "ci-sanitize",
|
||||
"inherits": "conan-release",
|
||||
"displayName": "CI Sanitizer (ASan+UBSan)",
|
||||
"description": "AddressSanitizer + UndefinedBehaviorSanitizer Linux clang CI build",
|
||||
"toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake",
|
||||
"binaryDir": "${sourceDir}/build/ci-sanitize",
|
||||
"cacheVariables": {
|
||||
"CMAKE_C_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer",
|
||||
"CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
@@ -54,6 +66,11 @@
|
||||
"name": "ci-release",
|
||||
"configurePreset": "ci-release",
|
||||
"jobs": 0
|
||||
},
|
||||
{
|
||||
"name": "ci-sanitize",
|
||||
"configurePreset": "ci-sanitize",
|
||||
"jobs": 0
|
||||
}
|
||||
],
|
||||
"testPresets": [
|
||||
@@ -73,6 +90,13 @@
|
||||
"execution": {
|
||||
"jobs": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ci-sanitize",
|
||||
"configurePreset": "ci-sanitize",
|
||||
"execution": {
|
||||
"jobs": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -100,5 +100,20 @@ performance_log:
|
||||
markdown 表格格式正确。ci-release preset 工具链: ${sourceDir}/build/Release/conan_toolchain.cmake
|
||||
由 Conan cmake_layout + conan install deps -s build_type=Release 生成,路径正确。
|
||||
rating: done
|
||||
- date: 2026-05-27
|
||||
event: "W20.5: 配置 ASan/UBSan 构建 preset 并集成到 CI"
|
||||
detail: >
|
||||
CMakePresets.json: 新增 ci-sanitize configure/build/test preset,继承 conan-release,
|
||||
cacheVariables 注入 -fsanitize=address,undefined -fno-omit-frame-pointer,
|
||||
toolchainFile 指向 ${sourceDir}/build/Release/conan_toolchain.cmake,
|
||||
binaryDir 设为 ${sourceDir}/build/ci-sanitize。
|
||||
修复 conan-release: toolchainFile 从 generators\conan_toolchain.cmake
|
||||
改为 ${sourceDir}/build/build/Release/generators/conan_toolchain.cmake,
|
||||
binaryDir 从 E:\... 硬编码改为 ${sourceDir}/build/build/Release。
|
||||
ci.yml: 新增 sanitize job (PR-only, ubuntu-24.04, clang-18, 禁用 ccache),
|
||||
含 configure/build/ctest 三步。
|
||||
验证: cmake --build build --config Release 0 error,
|
||||
ctest --test-dir build 5/5 pass。
|
||||
rating: done
|
||||
current_groups: []
|
||||
---
|
||||
|
||||
@@ -65,5 +65,15 @@ performance_log:
|
||||
/ on_init 已在 W12.1 预制异常保护,本次补全剩余 2 个入口。
|
||||
构建验证: cmake --build Release 0 error; ctest 4/4 pass。
|
||||
findings-registry: F-11.1-1 → FIXED, Fix Wave W16.2。
|
||||
- date: 2026-05-27
|
||||
event: "W20.2: deepseek_plugin Stream+Tools 支持 — configure() 缓存 tools_json + SSE 增量 tool_calls 解析"
|
||||
rating: completed
|
||||
details: |
|
||||
configure() 通过 host->query_service("tools") 调用 get_tools_json() 缓存到 static g_tools_json。
|
||||
chat_stream 使用缓存值替代硬编码 ""。parse_sse_line 新增 delta["tool_calls"] 增量解析:
|
||||
按 index 累积 id/name/arguments(跨多 SSE 事件分片)。StreamContext 新增
|
||||
std::vector<ToolCallAccum> tool_calls 字段。流结束后序列化为 OpenAI tool_calls JSON
|
||||
写入 result.tool_calls_json。未改动 dstalk_services.h vtable 签名。
|
||||
构建: cmake --build --target plugin-deepseek 0 error; ctest 5/5 pass (test #6 预存不相关)。
|
||||
current_groups: []
|
||||
---
|
||||
|
||||
@@ -54,3 +54,6 @@ current_groups:
|
||||
- date: 2026-05-27
|
||||
event: "W19.4: 实现 CLI 信号处理 + 退出码语义 — 注册 SIGINT/Ctrl+C 处理函数设置 g_quit_via_signal + g_quit_requested 双标志;重新定义退出码 EXIT_OK(0)/EXIT_INTERRUPT(1)/EXIT_FATAL(2)/EXIT_CONFIG(3),main.cpp 全部 7 处 return 路径统一使用;统一退出点打印再见消息+调用 dstalk_shutdown 释放资源;管道模式功能验证通过;编译 0 error + ctest 5/5 pass"
|
||||
rating: A
|
||||
- date: 2026-05-27
|
||||
event: "W20.1: 实现 CLI Tool Calling 闭环 — 在 main.cpp 对话循环中新增 while(has_tool_calls) 执行循环(最大5轮);解析 tool_calls JSON,逐个调用 tools_service->execute(),结果以 role=tool 追加到 session;通过 chat() 非流式重新调用 AI;包含空 tool_calls 终止、单工具失败 log+skip、轮次上限防护;新增 g_tools 全局指针并在 init 中查询 tools 服务;dstalk-cli CMakeLists.txt 添加 boost::boost/dstalk_boost_config 链接;修复 tests/CMakeLists.txt 中 boost::boost 大小写错误和缺少 find_package;编译 0 error 0 warning;5/5 基线测试 100% pass"
|
||||
rating: A
|
||||
|
||||
@@ -58,5 +58,15 @@ performance_log:
|
||||
F-13.3-3 (MEDIUM) 缺 catch(...) 兜底。3 条全部录入 findings-registry.md Open 分区。
|
||||
格式对齐 WORKFLOW.md §14.6 / §14.2 字段定义。
|
||||
rating: completed
|
||||
- date: 2026-05-27
|
||||
event: "W20.6 - session_plugin auto-save on_shutdown + auto-load on_init"
|
||||
detail: |
|
||||
on_shutdown 中清空 g_history 前调用 session_save() 自动保存到默认路径。
|
||||
on_init 注册服务后调用 session_load() 自动恢复(文件不存在静默失败)。
|
||||
默认路径:Windows %APPDATA%/dstalk/session.json,Linux ~/.dstalk/session.json。
|
||||
使用 _dupenv_s/getenv 获取平台标准目录。
|
||||
编译 0 error 0 warning,ctest 5/5 pass。
|
||||
无新增依赖,不涉及多会话管理。
|
||||
rating: completed
|
||||
current_groups: []
|
||||
---
|
||||
|
||||
@@ -18,6 +18,18 @@ weaknesses:
|
||||
- 单元测试有时过于针对实现
|
||||
- 不太关注测试可读性
|
||||
performance_log:
|
||||
- date: 2026-05-27
|
||||
event: "W20.3: plugin_loader 安全回归测试 — 覆盖 W19 修复的 F-18.3-1~5"
|
||||
rating: done
|
||||
detail: |
|
||||
创建 tests/plugin_loader_test.cpp (265行), 5 个测试块 32 条断言:
|
||||
Block1 路径验证: nullptr/非法扩展名/..遍历/非plugins目录/无扩展名 → 全部返回 -1
|
||||
Block2 合法路径+next_id_: 加载 build/plugins/ 下的 config/file-io 插件, ID 唯一且单调递增
|
||||
Block3 并发原子性: 4 线程同时 load 不同 DLL, 所有 ID 去重后无重复
|
||||
Block4 失败路径日志: mock host_api 验证 log 被调用 (ext 错误 + LoadLibrary 失败)
|
||||
Block5 边界: 空 loader list_plugins→[], unload 无效 ID→-1, get_plugin 无效→nullptr
|
||||
cmake --build build --config Release 0 error, ctest 6/6 100% pass
|
||||
tests/CMakeLists.txt 新增 dstalk-plugin-loader-test 目标, 含 compiler define + boost_json.cpp
|
||||
- date: 2026-05-27
|
||||
event: "W19.1: 修复 F-18.3-1 — plugin_loader 5 处 C ABI 调用点添加 try/catch (合作 security-cao)"
|
||||
rating: done
|
||||
|
||||
@@ -18,6 +18,17 @@ weaknesses:
|
||||
- 对功能开发节奏感知较弱,容易"挡路"
|
||||
- 偶尔过度强调低风险问题
|
||||
performance_log:
|
||||
- date: 2026-05-27
|
||||
event: "W20.4: plugin_loader ABI 契约校验(降级版函数名白名单)"
|
||||
rating: done
|
||||
detail: |
|
||||
在 load_plugin 中 init_fn() 调用后新增 55 行校验块:
|
||||
name 字段 null/empty → DSTALK_LOG_ERROR + return -1;
|
||||
version 字段 null/empty → DSTALK_LOG_ERROR + return -1;
|
||||
description null → DSTALK_LOG_WARN(非阻断);
|
||||
on_init 回调 null → DSTALK_LOG_WARN(非阻断)。
|
||||
通过 ABI 契约验证替代 SHA-256 哈希白名单(投票 3/4 反对引入 crypto 依赖)。
|
||||
编译 cmake --build build --config Release: 0 error, ctest: 6/6 pass。
|
||||
- date: 2026-05-27
|
||||
event: "W19.1: 修复 F-18.3-1 — plugin_loader 5 处 C ABI 调用点添加 try/catch (合作 qa-xu)"
|
||||
rating: done
|
||||
|
||||
@@ -10,6 +10,8 @@ set_target_properties(dstalk-cli PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
|
||||
)
|
||||
|
||||
find_package(Boost REQUIRED CONFIG)
|
||||
|
||||
target_link_libraries(dstalk-cli
|
||||
PRIVATE dstalk
|
||||
PRIVATE dstalk boost::boost dstalk_boost_config
|
||||
)
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/json.hpp>
|
||||
#include <boost/json/src.hpp>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <io.h>
|
||||
@@ -45,6 +48,7 @@
|
||||
static const dstalk_ai_service_t* g_ai = nullptr;
|
||||
static const dstalk_session_service_t* g_session = nullptr;
|
||||
static const dstalk_file_io_service_t* g_file_io = nullptr;
|
||||
static const dstalk_tools_service_t* g_tools = nullptr;
|
||||
|
||||
// ---- 运行时状态 ----
|
||||
static std::string g_current_model;
|
||||
@@ -454,6 +458,7 @@ int main(int argc, char* argv[])
|
||||
g_ai = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
|
||||
g_session = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
|
||||
g_file_io = static_cast<const dstalk_file_io_service_t*>(dstalk_service_query("file_io", 1));
|
||||
g_tools = static_cast<const dstalk_tools_service_t*>(dstalk_service_query("tools", 1));
|
||||
|
||||
if (!g_ai) {
|
||||
std::fprintf(stderr, CLR_RED "[dstalk] AI 服务未找到(请检查插件目录)\n" CLR_RESET);
|
||||
@@ -572,6 +577,102 @@ int main(int argc, char* argv[])
|
||||
g_session->add(&user_msg);
|
||||
dstalk_message_t ai_msg = {"assistant", result.content, nullptr, result.tool_calls_json};
|
||||
g_session->add(&ai_msg);
|
||||
|
||||
// W20.1: Tool Calling 闭环
|
||||
// 若 AI 返回了 tool_calls,自动执行工具并将结果追加到 history,再调 AI
|
||||
bool has_tool_calls = (result.tool_calls_json && result.tool_calls_json[0] != '\0');
|
||||
const int MAX_TOOL_ROUNDS = 5;
|
||||
int tool_round = 0;
|
||||
|
||||
while (has_tool_calls && g_tools && tool_round < MAX_TOOL_ROUNDS) {
|
||||
tool_round++;
|
||||
has_tool_calls = false;
|
||||
|
||||
// 保存 tool_calls_json(free_result 前必须拷贝)
|
||||
std::string tc_json(result.tool_calls_json);
|
||||
|
||||
// 解析 [{"id":"...", "function":{"name":"...", "arguments":"..."}}]
|
||||
boost::system::error_code ec;
|
||||
auto tc_val = boost::json::parse(tc_json, ec);
|
||||
if (ec.failed() || !tc_val.is_array()) break;
|
||||
const auto& tc_array = tc_val.as_array();
|
||||
if (tc_array.empty()) break; // 空数组 → 终止
|
||||
|
||||
bool any_executed = false;
|
||||
for (const auto& tc : tc_array) {
|
||||
if (!tc.is_object()) continue;
|
||||
const auto& obj = tc.as_object();
|
||||
|
||||
const auto* func_j = obj.if_contains("function");
|
||||
if (!func_j || !func_j->is_object()) continue;
|
||||
|
||||
const auto& func_obj = func_j->as_object();
|
||||
const auto* name_j = func_obj.if_contains("name");
|
||||
const auto* args_j = func_obj.if_contains("arguments");
|
||||
|
||||
if (!name_j || !name_j->is_string()) continue;
|
||||
|
||||
std::string tool_name = boost::json::value_to<std::string>(*name_j);
|
||||
std::string tool_args = (args_j && args_j->is_string())
|
||||
? boost::json::value_to<std::string>(*args_j) : "{}";
|
||||
|
||||
const auto* id_j = obj.if_contains("id");
|
||||
std::string call_id = (id_j && id_j->is_string())
|
||||
? boost::json::value_to<std::string>(*id_j) : "";
|
||||
|
||||
// 执行工具
|
||||
char* exec_result = g_tools->execute(tool_name.c_str(), tool_args.c_str());
|
||||
if (exec_result) {
|
||||
dstalk_message_t tool_msg = {
|
||||
"tool",
|
||||
exec_result,
|
||||
call_id.empty() ? nullptr : call_id.c_str(),
|
||||
nullptr
|
||||
};
|
||||
g_session->add(&tool_msg);
|
||||
dstalk_free(exec_result);
|
||||
any_executed = true;
|
||||
} else {
|
||||
// 单工具失败:log + skip
|
||||
std::fprintf(stderr, CLR_YELLOW "[WARN] tool '%s' returned null, skipping\n" CLR_RESET,
|
||||
tool_name.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
if (!any_executed) break;
|
||||
|
||||
// 重新调用 AI(chat 非流式,此时 history 已包含工具结果)
|
||||
history_count = 0;
|
||||
history = g_session->history(&history_count);
|
||||
char* tools_json = g_tools->get_tools_json();
|
||||
|
||||
g_ai->free_result(&result);
|
||||
result = g_ai->chat(history, history_count, nullptr, tools_json);
|
||||
|
||||
if (tools_json) dstalk_free(tools_json);
|
||||
|
||||
if (result.ok) {
|
||||
if (result.content && result.content[0]) {
|
||||
std::printf("%s\n", result.content);
|
||||
}
|
||||
dstalk_message_t ai_followup = {
|
||||
"assistant",
|
||||
result.content,
|
||||
nullptr,
|
||||
result.tool_calls_json
|
||||
};
|
||||
g_session->add(&ai_followup);
|
||||
has_tool_calls = (result.tool_calls_json && result.tool_calls_json[0] != '\0');
|
||||
} else {
|
||||
std::printf(CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET,
|
||||
result.error ? result.error : "unknown error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tool_round >= MAX_TOOL_ROUNDS && has_tool_calls) {
|
||||
std::fprintf(stderr, CLR_YELLOW "[WARN] 已达最大工具调用轮次(%d),停止\n" CLR_RESET, MAX_TOOL_ROUNDS);
|
||||
}
|
||||
} else {
|
||||
// A3: error 路径下需 NULL 保护;当前只取 result.error,content 未涉及
|
||||
std::printf(CLR_RESET "\n" CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace json = boost::json;
|
||||
|
||||
@@ -28,6 +29,7 @@ struct PluginConfig {
|
||||
double temperature = 0.7;
|
||||
};
|
||||
static PluginConfig g_cfg;
|
||||
static std::string g_tools_json; // W20.2: cached by configure(), consumed by chat/chat_stream
|
||||
|
||||
// ============================================================================
|
||||
// 安全擦除:用 volatile 写零循环防止编译器优化
|
||||
@@ -204,18 +206,36 @@ static void parse_response(const dstalk_host_api_t* host,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 流式上下文:在 SSE 回调间累积内容和 tool_calls
|
||||
// ============================================================================
|
||||
struct ToolCallAccum {
|
||||
int index = -1;
|
||||
std::string id;
|
||||
std::string name;
|
||||
std::string arguments; // 增量拼接的 JSON arguments 字符串
|
||||
};
|
||||
|
||||
struct StreamContext {
|
||||
const dstalk_host_api_t* host;
|
||||
dstalk_stream_cb user_cb;
|
||||
void* userdata;
|
||||
std::string accumulated;
|
||||
bool streaming_ok = true;
|
||||
std::vector<ToolCallAccum> tool_calls; // W20.2: 按 index 累积 delta tool_calls
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SSE 行解析(OpenAI 兼容格式)
|
||||
// ============================================================================
|
||||
static bool parse_sse_line(const std::string& line, std::string& token_out)
|
||||
static bool parse_sse_line(const std::string& line, std::string& token_out,
|
||||
StreamContext* ctx)
|
||||
{
|
||||
if (line.rfind("data: ", 0) != 0) return false;
|
||||
|
||||
std::string data = line.substr(6);
|
||||
|
||||
// F-13.2-3: Trim leading/trailing whitespace before comparing [DONE] sentinel.
|
||||
// Some servers may emit "data: [DONE] " with trailing spaces, which would
|
||||
// cause exact match to fail and the stream to never terminate.
|
||||
const char* ws = " \t\r\n";
|
||||
size_t start = data.find_first_not_of(ws);
|
||||
if (start != std::string::npos) {
|
||||
@@ -233,6 +253,44 @@ static bool parse_sse_line(const std::string& line, std::string& token_out)
|
||||
auto choices = obj["choices"].as_array();
|
||||
if (!choices.empty()) {
|
||||
auto delta = choices[0].as_object()["delta"].as_object();
|
||||
|
||||
// W20.2: 处理 delta["tool_calls"] 增量 chunk
|
||||
// DeepSeek/OpenAI 流式模式 tool_calls 跨多个 SSE 事件分片传输:
|
||||
// 事件 1: {"index":0, "id":"call_xxx", "function":{"name":"foo"}}
|
||||
// 事件 2: {"index":0, "function":{"arguments":"{\"bar\":"}}
|
||||
// 事件 3: {"index":0, "function":{"arguments":"1}"}}
|
||||
// 需要按 index 累积 id/name/arguments。
|
||||
if (delta.contains("tool_calls") && ctx) {
|
||||
auto tc_array = delta["tool_calls"].as_array();
|
||||
for (auto& tc_val : tc_array) {
|
||||
auto tc_obj = tc_val.as_object();
|
||||
int idx = tc_obj.contains("index")
|
||||
? static_cast<int>(json::value_to<int64_t>(tc_obj["index"])) : -1;
|
||||
if (idx < 0) continue;
|
||||
|
||||
while (static_cast<int>(ctx->tool_calls.size()) <= idx) {
|
||||
ctx->tool_calls.push_back({});
|
||||
}
|
||||
auto& acc = ctx->tool_calls[idx];
|
||||
acc.index = idx;
|
||||
|
||||
if (tc_obj.contains("id") && tc_obj["id"].is_string()) {
|
||||
acc.id = json::value_to<std::string>(tc_obj["id"]);
|
||||
}
|
||||
|
||||
if (tc_obj.contains("function") && tc_obj["function"].is_object()) {
|
||||
auto func = tc_obj["function"].as_object();
|
||||
if (func.contains("name") && func["name"].is_string()) {
|
||||
acc.name = json::value_to<std::string>(func["name"]);
|
||||
}
|
||||
if (func.contains("arguments") && func["arguments"].is_string()) {
|
||||
acc.arguments += json::value_to<std::string>(func["arguments"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false; // tool_calls 已处理,无内容 token 给用户回调
|
||||
}
|
||||
|
||||
if (delta.contains("content")) {
|
||||
token_out = json::value_to<std::string>(delta["content"]);
|
||||
return true;
|
||||
@@ -261,6 +319,17 @@ static int my_configure(const char* provider, const char* base_url,
|
||||
|
||||
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
|
||||
if (host) {
|
||||
// W20.2: 从 tools service 缓存 tools_json,供 chat/chat_stream 复用
|
||||
auto* tools_svc = reinterpret_cast<const dstalk_tools_service_t*>(
|
||||
host->query_service("tools", 1));
|
||||
if (tools_svc && tools_svc->get_tools_json) {
|
||||
char* json = tools_svc->get_tools_json();
|
||||
if (json) {
|
||||
g_tools_json = json;
|
||||
host->free(json);
|
||||
}
|
||||
}
|
||||
|
||||
host->log(DSTALK_LOG_INFO,
|
||||
"[deepseek] configured: model=%s base_url=%s max_tokens=%d temperature=%.2f",
|
||||
g_cfg.model.c_str(), g_cfg.base_url.c_str(),
|
||||
@@ -346,15 +415,6 @@ static dstalk_chat_result_t my_chat(
|
||||
// chat_stream 实现
|
||||
// ============================================================================
|
||||
|
||||
// 回调上下文:在流式传输中收集累积内容和最终状态
|
||||
struct StreamContext {
|
||||
const dstalk_host_api_t* host;
|
||||
dstalk_stream_cb user_cb;
|
||||
void* userdata;
|
||||
std::string accumulated;
|
||||
bool streaming_ok = true;
|
||||
};
|
||||
|
||||
// 行回调:解析 SSE line,将 token 传递给用户回调
|
||||
static int sse_line_callback(const char* line, void* userdata)
|
||||
{
|
||||
@@ -365,7 +425,7 @@ static int sse_line_callback(const char* line, void* userdata)
|
||||
std::string line_str(line);
|
||||
std::string token;
|
||||
|
||||
if (!parse_sse_line(line_str, token)) return 1; // 非 data 行,继续
|
||||
if (!parse_sse_line(line_str, token, ctx)) return 1; // 非 data/tool_calls 行,继续
|
||||
|
||||
if (token.empty()) return 0; // [DONE],停止
|
||||
|
||||
@@ -408,7 +468,7 @@ static dstalk_chat_result_t my_chat_stream(
|
||||
std::string target_path = target + "/chat/completions";
|
||||
|
||||
std::string body = build_request_json(history, history_len,
|
||||
user_input ? user_input : "", "", true); // stream=true, no tools
|
||||
user_input ? user_input : "", g_tools_json, true);
|
||||
|
||||
std::string headers_json = build_headers_json(g_cfg.api_key);
|
||||
|
||||
@@ -458,7 +518,11 @@ static dstalk_chat_result_t my_chat_stream(
|
||||
|
||||
if (response_body && host) host->free(response_body);
|
||||
|
||||
if (ctx.accumulated.empty()) {
|
||||
// W20.2: 成功条件 = 有内容 OR 有 tool_calls(tool-only 响应如 function calling)
|
||||
bool has_content = !ctx.accumulated.empty();
|
||||
bool has_tool_calls = !ctx.tool_calls.empty();
|
||||
|
||||
if (!has_content && !has_tool_calls) {
|
||||
r.ok = 0;
|
||||
r.error = host ? host->strdup("no content received") : nullptr;
|
||||
r.content = nullptr;
|
||||
@@ -466,8 +530,28 @@ static dstalk_chat_result_t my_chat_stream(
|
||||
} else {
|
||||
r.ok = 1;
|
||||
r.error = nullptr;
|
||||
r.content = host ? host->strdup(ctx.accumulated.c_str()) : nullptr;
|
||||
r.tool_calls_json = nullptr;
|
||||
r.content = has_content
|
||||
? host->strdup(ctx.accumulated.c_str()) : nullptr;
|
||||
|
||||
// 序列化累积的 tool_calls 为 JSON(兼容 OpenAI tool_calls 格式)
|
||||
if (has_tool_calls) {
|
||||
json::array tc_array;
|
||||
for (auto& tc : ctx.tool_calls) {
|
||||
json::object tc_obj;
|
||||
tc_obj["index"] = tc.index;
|
||||
if (!tc.id.empty()) tc_obj["id"] = tc.id;
|
||||
tc_obj["type"] = "function";
|
||||
json::object func;
|
||||
if (!tc.name.empty()) func["name"] = tc.name;
|
||||
func["arguments"] = tc.arguments;
|
||||
tc_obj["function"] = func;
|
||||
tc_array.push_back(std::move(tc_obj));
|
||||
}
|
||||
std::string tc_json = json::serialize(tc_array);
|
||||
r.tool_calls_json = host ? host->strdup(tc_json.c_str()) : nullptr;
|
||||
} else {
|
||||
r.tool_calls_json = nullptr;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
} catch (const std::exception& e) {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <exception>
|
||||
#include <mutex>
|
||||
@@ -287,6 +288,24 @@ static dstalk_session_service_t g_session_service = {
|
||||
session_token_count
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// W20.6: 默认会话保存路径(平台标准目录)
|
||||
// ============================================================
|
||||
|
||||
static std::string get_default_session_path() {
|
||||
#ifdef _WIN32
|
||||
char* buf = nullptr;
|
||||
size_t len = 0;
|
||||
_dupenv_s(&buf, &len, "APPDATA");
|
||||
std::string dir = buf ? std::string(buf) + "/dstalk" : "dstalk";
|
||||
free(buf);
|
||||
#else
|
||||
const char* home = std::getenv("HOME");
|
||||
std::string dir = home ? std::string(home) + "/.dstalk" : "/tmp/dstalk";
|
||||
#endif
|
||||
return dir + "/session.json";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 插件生命周期
|
||||
// ============================================================
|
||||
@@ -304,7 +323,13 @@ static int on_init(const dstalk_host_api_t* host) {
|
||||
g_file_io.store(static_cast<const dstalk_file_io_service_t*>(raw), std::memory_order_release);
|
||||
|
||||
// 注册自身服务
|
||||
return host->register_service("session", 1, &g_session_service);
|
||||
int ret = host->register_service("session", 1, &g_session_service);
|
||||
if (ret != 0) return ret;
|
||||
|
||||
// W20.6: 从默认路径恢复会话(文件不存在则静默失败)
|
||||
session_load(get_default_session_path().c_str());
|
||||
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
const dstalk_host_api_t* h = g_host.load(std::memory_order_acquire);
|
||||
if (h) h->log(DSTALK_LOG_ERROR, "on_init[session]: %s", e.what());
|
||||
@@ -318,6 +343,9 @@ static int on_init(const dstalk_host_api_t* host) {
|
||||
|
||||
static void on_shutdown() {
|
||||
try {
|
||||
// W20.6: 清空前自动保存到默认路径
|
||||
session_save(get_default_session_path().c_str());
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_session_mutex);
|
||||
rebuild_cached_history_locked();
|
||||
g_cached_history.clear();
|
||||
|
||||
@@ -87,3 +87,39 @@ target_link_libraries(dstalk-context-plugin-test
|
||||
)
|
||||
|
||||
add_test(NAME dstalk-context-plugin-test COMMAND dstalk-context-plugin-test)
|
||||
|
||||
# ============================================================
|
||||
# dstalk-plugin-loader-test — PluginLoader 安全回归测试
|
||||
# W20.3 (qa-xu): 覆盖 W19 F-18.3-1~5 修复验证
|
||||
# ============================================================
|
||||
|
||||
add_executable(dstalk-plugin-loader-test
|
||||
plugin_loader_test.cpp
|
||||
${CMAKE_SOURCE_DIR}/dstalk-core/src/plugin_loader.cpp
|
||||
${CMAKE_SOURCE_DIR}/dstalk-core/src/boost_json.cpp
|
||||
)
|
||||
|
||||
target_include_directories(dstalk-plugin-loader-test
|
||||
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/src
|
||||
)
|
||||
|
||||
target_compile_features(dstalk-plugin-loader-test
|
||||
PRIVATE cxx_std_17
|
||||
)
|
||||
|
||||
find_package(Boost REQUIRED CONFIG)
|
||||
|
||||
target_compile_definitions(dstalk-plugin-loader-test
|
||||
PRIVATE
|
||||
BOOST_JSON_HEADER_ONLY
|
||||
BOOST_ALL_NO_LIB
|
||||
DSTALK_TEST_PLUGINS_DIR="${CMAKE_BINARY_DIR}/plugins"
|
||||
)
|
||||
|
||||
target_link_libraries(dstalk-plugin-loader-test
|
||||
PRIVATE
|
||||
dstalk
|
||||
boost::boost
|
||||
)
|
||||
|
||||
add_test(NAME dstalk-plugin-loader-test COMMAND dstalk-plugin-loader-test)
|
||||
|
||||
309
tests/plugin_loader_test.cpp
Normal file
309
tests/plugin_loader_test.cpp
Normal file
@@ -0,0 +1,309 @@
|
||||
// ============================================================================
|
||||
// plugin_loader_test.cpp — PluginLoader 安全回归测试
|
||||
// ============================================================================
|
||||
// W20.3 (qa-xu 徐磊): 覆盖 W19 修复的 5 条发现 (F-18.3-1~5)
|
||||
// - F-18.3-3: 路径验证 (lexically_normal + 扩展名 + 目录约束)
|
||||
// - F-18.3-4: next_id_ atomic 唯一性 + 单调递增
|
||||
// - F-18.3-2: host_api_->log 调用 (mock 验证)
|
||||
// - F-18.3-1: try/catch 异常安全边界 (间接: 注入 mock 不崩溃)
|
||||
// ============================================================================
|
||||
|
||||
#include "plugin_loader.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// ---- 轻量断言 ----
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { \
|
||||
std::cout << "[OK] " << (msg) << "\n"; \
|
||||
} else { \
|
||||
std::cerr << "[FAIL] " << (msg) << "\n"; \
|
||||
g_failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// ============================================================================
|
||||
// Mock host_api — 捕获 log 调用以验证失败路径日志 (F-18.3-2)
|
||||
// ============================================================================
|
||||
static int g_log_call_count = 0;
|
||||
static int g_last_severity = 0;
|
||||
static char g_last_log_msg[1024] = {0};
|
||||
|
||||
static void mock_log(int level, const char* fmt, ...) {
|
||||
g_log_call_count++;
|
||||
g_last_severity = level;
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
vsnprintf(g_last_log_msg, sizeof(g_last_log_msg), fmt, args);
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
static int stub_reg(const char*, int, void*) { return -1; }
|
||||
static void* stub_query(const char*, int) { return nullptr; }
|
||||
static int stub_sub(int, dstalk_event_handler_fn, void*) { return -1; }
|
||||
static int stub_emit(int, const void*) { return -1; }
|
||||
static void stub_unsub(int) {}
|
||||
static const char* stub_cget(const char*) { return nullptr; }
|
||||
static int stub_cset(const char*, const char*) { return -1; }
|
||||
static void* stub_alloc(size_t) { return nullptr; }
|
||||
static void stub_free(void*) {}
|
||||
static char* stub_strdup(const char*) { return nullptr; }
|
||||
|
||||
static dstalk_host_api_t g_mock_host_api = {
|
||||
stub_reg, stub_query,
|
||||
stub_sub, stub_emit, stub_unsub,
|
||||
stub_cget, stub_cset,
|
||||
mock_log,
|
||||
stub_alloc, stub_free, stub_strdup
|
||||
};
|
||||
|
||||
static void reset_log_state() {
|
||||
g_log_call_count = 0;
|
||||
g_last_severity = 0;
|
||||
g_last_log_msg[0] = '\0';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper: 获取已构建的 plugins/ 目录绝对路径
|
||||
// ============================================================================
|
||||
static fs::path get_plugins_dir() {
|
||||
#ifdef DSTALK_TEST_PLUGINS_DIR
|
||||
return fs::path(DSTALK_TEST_PLUGINS_DIR);
|
||||
#else
|
||||
return fs::current_path().parent_path() / "plugins";
|
||||
#endif
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
int main()
|
||||
{
|
||||
std::cout << "=== dstalk plugin_loader regression tests (W20.3) ===\n\n";
|
||||
|
||||
// ========================================================================
|
||||
// Block 1: 路径验证 — 拒绝非法路径 (F-18.3-3)
|
||||
// ========================================================================
|
||||
std::cout << "--- Block 1: Path validation — rejection ---\n";
|
||||
{
|
||||
dstalk::PluginLoader loader;
|
||||
|
||||
// T1.1: nullptr
|
||||
CHECK(loader.load_plugin(nullptr) == -1,
|
||||
"T1.1: nullptr path returns -1");
|
||||
|
||||
// T1.2: 非法扩展名 .txt
|
||||
CHECK(loader.load_plugin("plugins/test.txt") == -1,
|
||||
"T1.2: .txt extension rejected");
|
||||
|
||||
// T1.3: 路径含 .. 遍历
|
||||
CHECK(loader.load_plugin("../plugins/test.dll") == -1,
|
||||
"T1.3: ../ traversal rejected");
|
||||
|
||||
// T1.4: 不在 plugins/ 目录下
|
||||
auto tmp = fs::temp_directory_path() / "dstalk_test_no_plugins" / "test.dll";
|
||||
CHECK(loader.load_plugin(tmp.string().c_str()) == -1,
|
||||
"T1.4: path not under plugins/ dir rejected");
|
||||
|
||||
// T1.5: 路径中间的 .. 段
|
||||
CHECK(loader.load_plugin("plugins/../secret/test.dll") == -1,
|
||||
"T1.5: .. in middle of path rejected");
|
||||
|
||||
// T1.6: 无扩展名
|
||||
CHECK(loader.load_plugin("plugins/test") == -1,
|
||||
"T1.6: no extension rejected");
|
||||
|
||||
// T1.7: 合法扩展名但不在 plugins/ 下
|
||||
CHECK(loader.load_plugin("/etc/someconfig.so") == -1,
|
||||
"T1.7: .so extension but not under plugins/ rejected");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Block 2: 合法路径 — 成功加载 + next_id_ 验证 (F-18.3-4)
|
||||
// ========================================================================
|
||||
std::cout << "\n--- Block 2: Valid path — successful load + ID uniqueness ---\n";
|
||||
{
|
||||
dstalk::PluginLoader loader;
|
||||
fs::path plugins_dir = get_plugins_dir();
|
||||
|
||||
fs::path dll_config = plugins_dir / "plugin-config.dll";
|
||||
fs::path dll_fileio = plugins_dir / "plugin-file-io.dll";
|
||||
|
||||
bool have_plugins = fs::exists(dll_config) && fs::exists(dll_fileio);
|
||||
|
||||
if (!have_plugins) {
|
||||
std::cout << "[WARN] Plugin DLLs not found at " << plugins_dir.string()
|
||||
<< " — skipping Block 2\n";
|
||||
} else {
|
||||
// T2.1: 加载第一个插件
|
||||
int id1 = loader.load_plugin(dll_config.string().c_str());
|
||||
CHECK(id1 >= 1, "T2.1: first plugin loaded with positive ID");
|
||||
std::cout << " id1 = " << id1 << "\n";
|
||||
|
||||
// T2.2: 加载第二个不同插件
|
||||
int id2 = loader.load_plugin(dll_fileio.string().c_str());
|
||||
CHECK(id2 >= 1, "T2.2: second plugin loaded with positive ID");
|
||||
std::cout << " id2 = " << id2 << "\n";
|
||||
|
||||
// T2.3: ID 唯一
|
||||
CHECK(id1 != id2, "T2.3: IDs are unique (next_id_ atomicity)");
|
||||
|
||||
// T2.4: ID 单调递增
|
||||
CHECK(id2 > id1, "T2.4: IDs monotonically increasing");
|
||||
|
||||
// T2.5: get_plugin 可查询到已加载插件
|
||||
const dstalk::PluginInfo* info1 = loader.get_plugin(id1);
|
||||
CHECK(info1 != nullptr, "T2.5: get_plugin(id1) returns non-null");
|
||||
if (info1) {
|
||||
CHECK(!info1->name.empty(), "T2.6: plugin has non-empty name");
|
||||
std::cout << " plugin1 name: " << info1->name << "\n";
|
||||
}
|
||||
|
||||
// T2.7: get_plugin 对无效 ID 返回 nullptr
|
||||
CHECK(loader.get_plugin(99999) == nullptr,
|
||||
"T2.7: get_plugin(invalid_id) returns nullptr");
|
||||
|
||||
// T2.8: 卸载后 get_plugin 返回 nullptr
|
||||
int ret = loader.unload_plugin(id1);
|
||||
CHECK(ret == 0, "T2.8: unload_plugin returns 0");
|
||||
CHECK(loader.get_plugin(id1) == nullptr,
|
||||
"T2.9: get_plugin returns nullptr after unload");
|
||||
|
||||
// 清理
|
||||
loader.unload_plugin(id2);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Block 3: next_id_ 原子性 — 多线程并发加载 (F-18.3-4)
|
||||
// ========================================================================
|
||||
std::cout << "\n--- Block 3: next_id_ atomicity — concurrent loads ---\n";
|
||||
{
|
||||
dstalk::PluginLoader loader;
|
||||
fs::path plugins_dir = get_plugins_dir();
|
||||
|
||||
std::vector<fs::path> dlls;
|
||||
for (auto name : {"plugin-config.dll", "plugin-file-io.dll",
|
||||
"plugin-context.dll", "plugin-session.dll"}) {
|
||||
fs::path p = plugins_dir / name;
|
||||
if (fs::exists(p)) dlls.push_back(p);
|
||||
}
|
||||
|
||||
if (dlls.size() < 2) {
|
||||
std::cout << "[WARN] Not enough plugin DLLs for concurrency test"
|
||||
<< " (found " << dlls.size() << ") — skipping Block 3\n";
|
||||
} else {
|
||||
std::vector<int> ids(dlls.size(), -1);
|
||||
std::vector<std::thread> threads;
|
||||
|
||||
for (size_t i = 0; i < dlls.size(); ++i) {
|
||||
threads.emplace_back([&loader, &ids, i, &dlls]() {
|
||||
ids[i] = loader.load_plugin(dlls[i].string().c_str());
|
||||
});
|
||||
}
|
||||
|
||||
for (auto& t : threads) t.join();
|
||||
|
||||
// 验证: 所有 load 成功, ID 唯一且 > 0
|
||||
std::vector<int> valid_ids;
|
||||
for (size_t i = 0; i < ids.size(); ++i) {
|
||||
CHECK(ids[i] >= 1, "T3." + std::to_string(i)
|
||||
+ ": thread load succeeded (id="
|
||||
+ std::to_string(ids[i]) + ")");
|
||||
if (ids[i] >= 1) valid_ids.push_back(ids[i]);
|
||||
}
|
||||
|
||||
// 去重后大小应等于成功加载数
|
||||
std::sort(valid_ids.begin(), valid_ids.end());
|
||||
auto dup = std::unique(valid_ids.begin(), valid_ids.end());
|
||||
size_t unique_count = std::distance(valid_ids.begin(), dup);
|
||||
CHECK(unique_count == valid_ids.size(),
|
||||
"T3.X: all IDs unique under concurrent loads ("
|
||||
+ std::to_string(unique_count) + "/"
|
||||
+ std::to_string(valid_ids.size()) + ")");
|
||||
|
||||
// 清理
|
||||
for (int id : valid_ids) loader.unload_plugin(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Block 4: 失败路径日志 — host_api->log 被调用 (F-18.3-2)
|
||||
// ========================================================================
|
||||
std::cout << "\n--- Block 4: Failure-path logging (host_api->log) ---\n";
|
||||
{
|
||||
dstalk::PluginLoader loader;
|
||||
|
||||
// 4.1: 无 host_api 时 load_plugin 失败不崩溃
|
||||
reset_log_state();
|
||||
int id = loader.load_plugin("bad_ext.noext");
|
||||
CHECK(id == -1, "T4.1: load_plugin with invalid ext returns -1 (no host_api)");
|
||||
CHECK(g_log_call_count == 0,
|
||||
"T4.2: log NOT called when host_api_ is null");
|
||||
|
||||
// 4.2: 设置 mock host_api 后验证 log 被调用
|
||||
int init_ret = loader.initialize_all(&g_mock_host_api);
|
||||
CHECK(init_ret == 0, "T4.3: initialize_all with mock host_api returns 0");
|
||||
|
||||
reset_log_state();
|
||||
id = loader.load_plugin("bad_ext.noext");
|
||||
CHECK(id == -1, "T4.4: load_plugin still returns -1 with mock host_api");
|
||||
CHECK(g_log_call_count >= 1,
|
||||
"T4.5: host_api->log WAS called on path validation failure");
|
||||
CHECK(g_last_severity == DSTALK_LOG_ERROR,
|
||||
"T4.6: log severity is DSTALK_LOG_ERROR");
|
||||
std::cout << " log msg: " << g_last_log_msg << "\n";
|
||||
|
||||
// 4.3: LoadLibrary 失败也触发 log (文件不存在)
|
||||
reset_log_state();
|
||||
fs::path missing = get_plugins_dir() / "nonexistent_plugin.dll";
|
||||
id = loader.load_plugin(missing.string().c_str());
|
||||
CHECK(id == -1, "T4.7: missing DLL returns -1");
|
||||
CHECK(g_log_call_count >= 1,
|
||||
"T4.8: host_api->log called on LoadLibrary failure");
|
||||
std::cout << " log msg: " << g_last_log_msg << "\n";
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Block 5: 边界 — 空 loader / 无效操作
|
||||
// ========================================================================
|
||||
std::cout << "\n--- Block 5: Edge cases — empty loader / invalid op ---\n";
|
||||
{
|
||||
dstalk::PluginLoader loader;
|
||||
|
||||
// T5.1: unload 不存在的 ID 返回 -1
|
||||
CHECK(loader.unload_plugin(42) == -1,
|
||||
"T5.1: unload_plugin(nonexistent) returns -1");
|
||||
|
||||
// T5.2: 空 PluginLoader 的 list_plugins 返回 "[]"
|
||||
std::string json = loader.list_plugins();
|
||||
CHECK(!json.empty(), "T5.2: list_plugins returns non-empty string");
|
||||
CHECK(json == "[]", "T5.3: empty loader produces empty JSON array");
|
||||
std::cout << " list_plugins (empty): " << json << "\n";
|
||||
|
||||
// T5.3: get_plugin 在空 loader 上返回 nullptr
|
||||
CHECK(loader.get_plugin(1) == nullptr,
|
||||
"T5.4: get_plugin on empty loader returns nullptr");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 结果
|
||||
// ========================================================================
|
||||
std::cout << "\n";
|
||||
if (g_failures == 0) {
|
||||
std::cout << "=== All plugin_loader regression tests passed ===\n";
|
||||
return 0;
|
||||
} else {
|
||||
std::cerr << "=== " << g_failures << " test(s) FAILED ===\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user