W20: Tool Calling 闭环 + Stream+Tools + 回归测试 + session auto-save + ASan CI (W20.1-W20.6)
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled

- 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:
2026-05-27 20:15:00 +08:00
parent 3250b5a8bf
commit 20ead86e88
14 changed files with 730 additions and 21 deletions

View File

@@ -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)

View 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;
}
}