diff --git a/agents/architect-huang/profile.md b/agents/architect-huang/profile.md index eb12ffa..72b6f43 100644 --- a/agents/architect-huang/profile.md +++ b/agents/architect-huang/profile.md @@ -21,14 +21,16 @@ performance_log: event: "入职 dstalk 团队" rating: ongoing - date: 2026-05-27 - event: "W9.8 修复 plugin_loader initialize_all() 首插件失败即终止缺陷" + event: "W11.1 审计 context_plugin.cpp (289行,零Wave覆盖)" detail: | - 将 initialize_all() 从 fail-fast 改为 fail-continue: - - 单插件 init 失败不再返回 -1,而是 log error + 标记失败 + 继续初始化其他插件 - - 依赖了 failed 插件的插件自动跳过,log warning - - 拓扑序不变(Kahn 算法未修改) - - 返回值语义: 0=全部成功, >0=失败插件数, <0=严重错误(循环依赖/host_api null) - - 编译 0 error, smoke test 100% pass + context_plugin 首次审计, 聚焦跨 DLL 堆合规 / ABI 契约 / 内存泄漏 / 并发安全: + - 堆纪律: 完全合规 (0 处裸 malloc/free/strdup/new/delete), 无需迁移。所有跨边界分配使用 host->alloc/strdup。 + - ABI: 基本合规, 但违反 §5.3 (trim_impl 内 std::vector/std::string 可抛异常穿越 C ABI 边界→std::terminate) + - 内存: 正常路径干净; OOM 路径 g_host->strdup 返回值未检查 (L138-141/L219-222), 8 处调用无 null guard + - 并发: g_host 在 on_shutdown 与 trim_impl 间无同步访问, 隐式时序依赖 (评级 C) + - Top3: (1) C++异常穿越ABI边界[严重] (2) strdup返回值未检查+泄漏[高] (3) g_max_tokens设置但无读取点→set_max_tokens是死API[中] + - 综合评级: B (堆纪律A, ABI B, 内存B, 并发C) + 审计报告写入 agents/audits/W11.1-context-audit.md rating: completed current_groups: [] --- diff --git a/agents/audits/W11.1-context-audit.md b/agents/audits/W11.1-context-audit.md new file mode 100644 index 0000000..4ed4d6c --- /dev/null +++ b/agents/audits/W11.1-context-audit.md @@ -0,0 +1,164 @@ +# W11.1 Context Plugin Audit + +**Auditor**: 黄岭 (architect-huang) +**Date**: 2026-05-27 +**File**: plugins/context/src/context_plugin.cpp (289 行) +**Wave Coverage**: 零 (从未被 Wave 流程审计) + +--- + +## 1. 跨 DLL 堆合规性 + +状态: **合规** ✅ + +逐行检查所有 malloc/free/strdup/new/delete 调用点: + +| 调用类型 | 搜索结果 | 判定 | +|----------|----------|------| +| `malloc()` | 0 处 | -- | +| `free()` | 0 处 | -- | +| `strdup()` (裸) | 0 处 | -- | +| `new` (显式) | 0 处 | -- | +| `delete` (显式) | 0 处 | -- | +| `g_host->alloc()` | L135, L215 | 正确: host 堆分配 | +| `g_host->strdup()` | L138-141, L219-222 | 正确: host 堆分配 | +| `std::string` / `std::vector` | L90-127 等多处 | 插件内部使用, 不跨边界 | + +**结论**: 所有跨 DLL 边界的分配均通过 `g_host->alloc()` / `g_host->strdup()`, 符合 §3.2 硬性规则。内部 C++ 类型 (std::string/std::vector) 仅用于 TrimMessage 局部数据, 不出 DLL 边界。W2.1 堆纪律已隐式合规, 无需迁移。 + +**注意**: L159/L168 使用 `std::fprintf(stderr, ...)`, 非内存操作, 无堆问题。 + +--- + +## 2. ABI 契约符合度 (plugin-abi.md §1-§7) + +### §1 DSTALK_API_VERSION +- L280: `DSTALK_API_VERSION` ✅ +- 导出函数签名 `dstalk_plugin_info_t* dstalk_plugin_init(void)` 与 `dstalk_plugin_init_fn` typedef 匹配 ✅ + +### §2 内存所有权 +- `g_info.name/version/description` 为静态字符串字面量, 生命周期覆盖 init_fn 调用 ✅ (§2.2) +- 输出数组通过 `g_host->alloc` + `g_host->strdup` 分配, 调用方负责 `g_host->free` ✅ (§2.3) + +### §3 跨 DLL 堆 +- 已在上节验证 ✅ + +### §4 register_service +- 仅 `on_init` 内调用 (L268) ✅ (§4.1) +- 版本号 1 ✅ + +### §5 on_init/on_shutdown +- 函数签名匹配: `int (*)(const dstalk_host_api_t*)` / `void (*)(void)` ✅ +- **违反 §5.3 (异常安全)**: `on_init` / `context_count_tokens` / `context_trim` / `context_set_max_tokens` 均定义为 C 函数指针, 但底层 C++ 实现 (`trim_impl` L114-226) 使用了 `std::vector` / `std::string`, 二者均可抛出 `std::bad_alloc`。无任何 `try/catch` 包裹。若 OOM, 异常穿越 C ABI 边界 → `std::terminate()`, 违反 §5.3 约束。 + + 受影响函数: `context_trim` → `trim_impl` (L114), `on_init` → `g_host->log` (可变参数, 低风险) + +### §6 线程安全 +- 见第 4 节 + +### §7 依赖声明 +- L281: `{"session", nullptr, ...}`, NULL 终止 ✅ +- `on_init` 内通过 `host->query_service("session", 1)` 验证依赖可用 (L261-265) ✅ + +### 评级: 基本合规, 但因 §5.3 异常安全缺失降为 **B** + +--- + +## 3. 内存/资源泄漏分析 + +### 3.1 服务注册 +- `on_init` 注册 "context" 服务 (L268), 静态 vtable `g_context_service` 无动态分配。 +- `on_shutdown` (L271-274) 未调用 `unregister_service` — **但合规**: 按 §4.4, host 在插件卸载时自动调用 `ServiceRegistry::unregister_service`。无需手动清理。 + +### 3.2 事件订阅 +- 无事件订阅, 无泄漏风险 ✅ + +### 3.3 trim_impl 内存分配路径 +三条返回路径分析: + +| 路径 | 位置 | 内存状态 | +|------|------|----------| +| 单条消息超限, 提前返回 | L172-174 | `*out=nullptr`, 无分配, 局部 vector RAII 清理 ✅ | +| 完整副本路径 (current ≤ max) | L133-144 | `alloc` + `strdup` x N, 全部返回给调用方 ✅ | +| 裁剪后路径 | L213-225 | `alloc` + `strdup` x N, 全部返回给调用方 ✅ | + +**发现**: `g_host->strdup` (L138-141, L219-222) 返回值未被检查。若 strdup 在循环中途失败返回 nullptr: +- 前置已成功的 strdup 分配被**泄漏** (无记录, 无法逐个释放) +- nullptr 被静默存入 message field, 函数返回 0 (成功) +- 调用方解引用 nullptr 的 role/content → 崩溃 + +**结论**: 正常路径无泄漏, 但 OOM 路径存在资源泄漏 + 空指针隐患。 + +### 3.4 g_session 死引用 +- L261-266: `query_service("session")` 获取并存入 `g_session` +- 全文搜索: `g_session` **从未被读取** (仅在 L272 `on_shutdown` 中置 null) +- 不造成泄漏 (无额外分配), 但 dead code 暗示设计与实现脱节 + +### 评级: **B** (正常路径干净, OOM 路径脆弱) + +--- + +## 4. 并发安全分析 + +### 4.1 共享状态清单 + +| 变量 | 写入点 | 读取点 | 同步 | +|------|--------|--------|------| +| `g_host` | L258 (on_init), L273 (on_shutdown) | L135/215 (trim_impl → alloc), L138-141/219-222 (strdup) | **无** | +| `g_session` | L266 (on_init), L272 (on_shutdown) | 无读取 | **无** | +| `g_max_tokens` | L244 (set_max_tokens) | 无读取 | **无** | + +### 4.2 潜在竞争 + +**Race A: on_shutdown vs. trim_impl 并发** (g_host) +- 线程 T1: `context_trim()` → `trim_impl()` 读取 `g_host` (非 null) +- 线程 T2: `on_shutdown()` 写入 `g_host = nullptr` +- T1 随后调用 `g_host->alloc()` → **空指针解引用** +- 缓解因素: host 应保证 shutdown 前无 in-flight 服务调用。但 ABI 未显式保证, 属未定义行为边界。 + +**Race B: set_max_tokens vs. 未来的 getter** (g_max_tokens) +- 当前无读取点, 不构成实际竞争。但 `set_max_tokens` 作为公开 API, 未来添加 getter 时需 `std::atomic` 保护。当前为**潜在债务**。 + +### 评级: **C** (g_host 读写的无同步访问是数据竞争, 虽有隐式时序假设但不可依赖) + +--- + +## 5. 最严重发现 (Top 3) + +### 发现 1 — [严重] C++ 异常穿越 ABI 边界 (违反 §5.3) +- **行号**: L114-226 (trim_impl), L257-268 (on_init), L232-240 (vtable 函数) +- **问题**: `trim_impl` 使用 `std::vector`/`std::string` 可抛 `std::bad_alloc`, 但无 `try/catch`。异常通过 C 函数指针边界 → `std::terminate()` → 进程崩溃。违反 plugin-abi §5.3 硬性约束。 +- **修复方向**: 在 `trim_impl` 体和 `on_init` 体加 `try { ... } catch (...) { return -1; }`, 或预分配 + nothrow 版本。 + +### 发现 2 — [高] strdup 返回值未检查, OOM 时静默失败 + 泄漏 +- **行号**: L138-141 (完整副本路径), L219-222 (裁剪后路径) +- **问题**: 循环内连续 4 次 `g_host->strdup` 调用均未检查返回值。若某次返回 nullptr: (a) 前置成功的 strdup 分配泄漏 (无释放手段); (b) nullptr 存入 message field, 函数返回 0 (成功); (c) 调用方解引用 null role/content → 段错误。 +- **修复方向**: 每个 strdup 后检查返回值, 失败时逆序释放已分配字段, 再 `g_host->free(*out)`, 然后返回 -1。 + +### 发现 3 — [中] context_set_max_tokens 是死 API (g_max_tokens 未被读取) +- **行号**: L21 (声明), L243-244 (写入) +- **问题**: `context_set_max_tokens()` 写入 `g_max_tokens`, 但全文无任何代码路径读取该变量。`trim_impl` 使用参数 `max_tokens` (调用方直接传入), 不依赖全局状态。因此 `set_max_tokens` 调用对行为零影响——调用方期待的限制永不生效。 +- **修复方向**: 要么在 `context_trim` 中读取 `g_max_tokens` 作为默认值 (当调用方传 0 时), 要么删除该 API / 改为返回当前值。 + +--- + +## 6. 整体评级 + +| 维度 | 评级 | +|------|------| +| 跨 DLL 堆合规 | A (完全合规) | +| ABI 契约符合度 | B (核心合规, §5.3 异常安全缺失) | +| 内存/资源泄漏 | B (正常路径干净, OOM 脆弱) | +| 并发安全 | C (g_host 无同步访问, 隐式时序依赖) | +| **综合** | **B** | + +**总评**: context_plugin 在堆纪律上意外地干净 (没有需要迁移的裸 malloc/free/strdup/new/delete), 说明编码者即使未读 plugin-abi 也遵循了正确模式。主要风险集中在异常安全 (可导致进程崩溃) 和 OOM 鲁棒性。无安全漏洞、无明确内存泄漏、无崩溃性逻辑错误。 + +--- + +## 7. 补充发现 (优先级低) + +- **UTF-8 解码无越界保护** (L42-64, L96-104): 多字节序列 (2/3/4 字节) 的 `i += N` 假设后续字节有效, 若输入截断则读越界。对 token 估算影响极小 (仅计数值偏差), 非功能性 bug。 +- **token 计数逻辑重复** (L34-68 vs L91-106): `count_tokens_one_message` 与 `count_tokens_trim` 有 ~90% 重复, 在 C 字符串和 std::string 上各实现一套。维护风险 (两端需同步修改), 但不影响正确性。 +- **0xC0/0xC1 过短编码未识别** (L52, L100): UTF-8 标准中 0xC0/0xC1 是无效起始字节 (过短编码), 但仍计入 `other_chars`。仅影响 token 估算计数, 不影响功能。 +- **on_shutdown 不释放 g_info.dependencies** (L281): dependencies 是静态字符串数组, 无需释放, 无问题。 diff --git a/agents/audits/W11.2-config-audit.md b/agents/audits/W11.2-config-audit.md new file mode 100644 index 0000000..4b44b20 --- /dev/null +++ b/agents/audits/W11.2-config-audit.md @@ -0,0 +1,108 @@ +# W11.2 Config Plugin / ConfigStore 职责与跨 DLL 堆审计 + +**审计人**: 陈风 (engineer-chen) +**日期**: 2026-05-27 +**审计范围**: `plugins/config/src/config_plugin.cpp` (146行) + `dstalk-core/src/config_store.cpp` (83行) + +--- + +## 1. 职责划分 + +| 文件 | 职责 | 所在层 | +|------|------|--------| +| `config_store.cpp` (dstalk::ConfigStore) | Host 自身的键值配置存储。`dstalk_init` 加载初始配置,通过 `host->config_get/set` 供所有插件调用 | core | +| `config_plugin.cpp` (匿名 ConfigStore) | 注册 "config" 服务 (vtable `dstalk_config_service_t`),提供 `load_file` 运行时调用接口,供其他插件通过 `query_service` 发现 | plugin | + +**结论**: 职责有交集但不完全重复——plugin 提供了 core 未暴露的 `load_file` 运行时接口。然而,两个 ConfigStore 的 TOML 解析逻辑(74行)**逐字符完全相同**,且两个 config 存储在运行时相互独立(host 的 config 与 plugin 的 config 是不同的 map),存在以下问题: + +- 同一 key 在两个 store 中可能具有不同值 +- `host->config_get("plugin_dir")` 读的是 core store,而 `query_service("config")->get("plugin_dir")` 读的是 plugin store +- 用户不知道该用哪个接口 + +**建议边界**: core 的 ConfigStore 作为唯一真相源;config plugin 不再持有自己的 store,改为将 `load_file` / `get` / `set` 委托给 `g_host->config_get` / `g_host->config_set`。`load_file` 则需要 core 侧新增 `config_load_file` 到 host API 或 config plugin 自行实现(仅保留解析逻辑,写入 host store)。 + +--- + +## 2. 跨 DLL 堆合规 + +| 检查项 | config_store.cpp | config_plugin.cpp | +|--------|-----------------|-------------------| +| 直接 `malloc`/`free` | 无 | 无 | +| 直接 `strdup` | 无 | 无 | +| `new`/`delete` 跨边界 | 无(仅内部 STL 容器) | 无 | +| `get()` 返回 `const char*` 的所有权 | 指向内部 `std::string`,调用方不得释放 | 同左 | +| `set()` 的值类型 | `const char*` 输入,拷贝到 `std::string` | 同左 | + +**结论**: **无跨 DLL 堆违规**。两个文件均完全使用 STL 容器(`std::unordered_map`)管理内存,所有分配/释放均在各自 DLL 的 CRT 堆内完成。返回的 `const char*` 指向内部 string buffer,调用方只读不释放,符合 ABI 契约 2.1。 + +但需注意:`config_plugin.cpp:77` 返回的 `c_str()` 指向 plugin DLL 内部的 `std::string`。调用方若持有该指针跨越 plugin unload,将导致 use-after-free。这在当前设计中是低风险(config plugin 通常不会被卸载),但建议在 ABI 文档中明确说明。 + +--- + +## 3. 线程安全 + +| 文件 | 锁机制 | 评估 | +|------|--------|------| +| config_store.cpp | `mutable std::mutex`,get/set 均持锁 | get/set 单独调用安全 | +| config_plugin.cpp | 同上(匿名类内 `std::mutex`) | 同左 | + +**已知问题**: + +- **Dangling pointer (config_store.cpp:72, config_plugin.cpp:77)**: `get()` 在锁内获取 `it->second.c_str()`,锁释放后返回。并发 `set()` 同一 key 会触发 `std::string` 重分配,使外部持有的指针悬垂。ABI 文档 6.4 已记录此风险但代码未做防护(如返回 `std::string` 副本或用 `host->strdup` 分配)。 + +- **load_file 非原子 (config_store.cpp:57-60, config_plugin.cpp:63-66)**: `load_file` 逐行解析,每写入一个 key-value 就释放锁。并发 `get()` 可观察到新旧配置混合的状态。设计上这是合理的(避免持锁做磁盘 I/O),但调用方需知晓。 + +- **ServiceRegistry 与 ConfigStore 之间无锁协调**: plugin 注册 "config" 服务时(`config_plugin.cpp:126`),ServiceRegistry 的写锁和 ConfigStore 的 mutex 是独立的。这在当前无问题(注册只存 vtable 指针),但如果未来 config service 的注册/卸载与 store 生命周期联动,需注意锁序。 + +--- + +## 4. 服务暴露 + +`config_plugin.cpp:124-127`: +```cpp +static int on_init(const dstalk_host_api_t* host) { + g_host = host; + return host->register_service("config", 1, &g_service); +} +``` + +- **正确**: 在 `on_init` 期间注册,符合 ABI 契约 4.1(仅此时可注册) +- **正确**: vtable 为 static 全局变量,生命周期覆盖插件整个加载期(ABI 4.4) +- **正确**: service name "config" 与 `dstalk_services.h:64` 定义一致 +- **注意**: 未检查 `register_service` 返回值。如果另一个插件也注册 "config",返回 -2 会被静默忽略 +- **去重**: ServiceRegistry 已实现重复注册检测(`service_registry.cpp:12-14`),但插件自身不感知失败 + +--- + +## 5. 三个最严重发现 + +### 发现 1: 完整 TOML 解析器代码重复 (Critical) +- **config_plugin.cpp:16-90** 与 **config_store.cpp:10-83** 的解析逻辑完全相同 +- **影响**: 任何 bug 修复需双份维护;已发现 load_file 不处理 section 名前后空格、不支持 inline table 等问题,需在两个文件中分别修复 +- **修复方向**: config plugin 消除自己的 ConfigStore,改为委托 host store(通过 `g_host->config_get/set`),仅保留 `load_file` 且将解析结果写入 host store + +### 发现 2: 双配置存储导致数据孤岛 (High) +- **位置**: `host.cpp:176` 创建 `dstalk::ConfigStore`(host store);`config_plugin.cpp:98` 创建匿名 `ConfigStore`(plugin store) +- **影响**: `host->config_get("key")` 和 `query_service("config")->get("key")` 返回不同数据,用户困惑 +- **修复方向**: 合并为唯一 store(建议保留 host 侧 `dstalk::ConfigStore`,config plugin 只做服务注册包装) + +### 发现 3: get() 返回悬垂指针 (High) +- **config_store.cpp:72** / **config_plugin.cpp:77**: `return it->second.c_str()` 在锁释放后,并发 set 同一 key 触发 realloc +- **影响**: 调用方持有的 `const char*` 可能指向已释放内存,导致 crash 或静默数据损坏 +- **修复方向**: 选项 A — 返回值拷贝(调用方用 `host->strdup` 在锁内复制);选项 B — 文档明确规定调用方必须立即复制(当前 ABI spec 6.4 方向);选项 C — 用 `std::string` 作为返回值类型(需改 vtable 签名,涉及 API 版本 bump) + +--- + +## 6. 整体评级 + +**评级: C** + +**理由**: 无跨 DLL 堆违规(本次审计核心关注点通过),线程安全基础正确但 get() 存在悬垂指针缺陷。最严重的问题是 74 行 TOML 解析器完全重复 + 双存储架构混乱,属于设计层面而非实现 bug,但维护代价和用户困惑程度显著。建议优先解决发现 1 和 2(合并 store),发现 3 在合并时一并修复。 + +--- + +## 7. 附加建议 + +1. **config plugin 的 `on_init` 检查返回值** (config_plugin.cpp:126): `register_service` 返回 -2 时应有日志警告 +2. **load_file 前清空已有数据**: 当前 `load_file` 不先 `data_.clear()`,新文件与旧数据混合。如果这是预期行为(merge 语义),应在注释中说明 +3. **config_store.hpp 缺少 `load_file` 文档**: 未说明返回值约定(0=成功, -1=文件不存在/解析失败) diff --git a/agents/audits/W11.7-destructive-test.md b/agents/audits/W11.7-destructive-test.md new file mode 100644 index 0000000..87580be --- /dev/null +++ b/agents/audits/W11.7-destructive-test.md @@ -0,0 +1,53 @@ +# W11.7 破坏性输入测试报告 + +**测试人**: 徐磊 (qa-xu) +**日期**: 2026-05-27 +**被测二进制**: `build/dstalk-cli/dstalk-cli.exe` (MD5: 803ca2ea) +**源 Commit**: `004a81d` (Wave 7: W10.1-W10.4) +**测试环境**: Windows 11 Pro, 无 API key, 无 config.toml, 无 plugins/ + +## 测试结果汇总 + +| # | 场景 | 结果 | 退出码 | 备注 | +|---|------|------|--------|------| +| 1 | 启动+EOF (空pipe) | PASS | 0 | 直接进入 shutdown,干净退出 | +| 2 | 空字符串回车 | PASS | 0 | fgets→len==0→continue 正确跳过 | +| 3 | 10000 字符超长行 | PASS | 0 | 截断警告正常触发,剩余字符 drain 正确 | +| 4 | 纯空格/纯Tab | PASS | 0 | 被当 AI prompt,AI 不可用报错不崩溃 | +| 5 | 未知 /command | PASS | 0 | "未知命令" 提示正常 | +| 6 | /status + /history 无session | PASS | 0 | /status 正常显示默认值;/history 报 "session service unavailable" | +| 7 | 中文+emoji prompt | PASS | 0 | UTF-8 无问题,AI 不可用报错不崩溃 | +| 8 | ASCII 控制字符 \x01-\x08 | PASS | 0 | 被当 AI prompt 处理,无崩溃 | +| 9 | /quit 后重启 | PASS | 0,0 | 两次启动无残留状态 | +| 10 | 空 stdin (printf '') | PASS | 0 | fgets 立即返回 NULL,干净退出 | + +**结论: 10/10 全部 PASS,零崩溃,零 hang。** + +## 发现的 Bug + +### BUG-1 [CRITICAL] build/bin/dstalk-cli.exe 是损坏副本 + +- **MD5**: d8e8c92b (67,072 bytes) vs 正常 803ca2ea (66,048 bytes) +- **症状**: 所有命令(含 /help、/quit)被当 AI prompt 处理;任何输入退出码固定为 3;错误信息是英文("AI or session service unavailable")而非正常的中文 +- **推测**: CMake install/post-build 步骤拷贝了不同版本源码编译的产物,类似 PM-001 stale obj 模式 +- **影响**: 任何人若按文档路径 `build/bin/dstalk.exe` 运行将得到完全不可用版本 + +### BUG-2 [MEDIUM] /clear 在 session 不可用时谎报成功 + +- `main.cpp:168-172`: `if (g_session) g_session->clear()` 后无条件 `printf("[OK] 会话已清空")` +- g_session==null 时也打印成功,误导用户 + +### BUG-3 [LOW] /context 在 session 不可用时静默无输出 + +- `main.cpp:175-185`: 仅 `if (g_session) {...}` 无 else 分支 +- 用户输入 /context 后零反馈,不知所措 + +### BUG-4 [LOW] /file write 裸命令被当未知命令 + +- `strncmp(line, "/file write ", 12)` 要求尾部空格,`/file write` (无参) 匹配失败落入"未知命令" +- 应给用法提示而非未知命令 + +## 变更历史 +| 日期 | 版本 | 变更 | +|------|------|------| +| 2026-05-27 | 1.0 | W11.7 破坏性测试,10 场景全 PASS,发现 4 个 bug (1 critical) | diff --git a/agents/engineer-chen/profile.md b/agents/engineer-chen/profile.md index c70950c..d163826 100644 --- a/agents/engineer-chen/profile.md +++ b/agents/engineer-chen/profile.md @@ -33,5 +33,17 @@ performance_log: - "编译: 0 error; 测试: smoke test passed" - "发现: initialize_all() 在首个插件失败时停止,使后续插件无法初始化 (预存 bug, 非本次引入)" - "发现: deepseek/session 插件 Boost JSON 链接错误 (预存问题, 与本次修复无关)" + - date: 2026-05-27 + event: "W11.2 - 审计 config_plugin / ConfigStore 职责划分与跨 DLL 堆合规" + rating: success + details: + - "跨 DLL 堆: 无违规。两个文件均完全使用 STL 容器,无 malloc/free/strdup 直接调用" + - "代码重复: config_plugin.cpp:16-90 与 config_store.cpp:10-83 的 TOML 解析器完全相同 (74行)" + - "架构问题: 双 ConfigStore 导致数据孤岛 -- host->config_get 与 query_service('config')->get 读不同 store" + - "悬垂指针: get() 返回 std::string::c_str() 后释放锁,并发 set 同 key 触发 realloc 导致 dangling" + - "线程安全: mutex 基础正确,但 load_file 逐行持锁导致并发 get 可见部分新/旧混合配置" + - "服务注册: on_init 内注册正确,但未检查 register_service 返回值 (重复注册返回 -2 被忽略)" + - "评级: C (无跨 DLL 堆违规但代码重复 + 双 store 架构 + dangling pointer 需修复)" + - "输出: agents/audits/W11.2-config-audit.md" current_groups: [] --- diff --git a/agents/engineer-li/profile.md b/agents/engineer-li/profile.md index ddb9b23..11ad1e8 100644 --- a/agents/engineer-li/profile.md +++ b/agents/engineer-li/profile.md @@ -23,5 +23,8 @@ performance_log: - date: 2026-05-27 event: "W10.2: 创建 agents/STATUS.md 实时编制状态文档(63行),含员工状态表(16人)+ 工作组状态表(5组)+ Wave 进度,一次性读完 16 份 profile + 5 份 group + git log 后整理输出" rating: completed + - date: 2026-05-27 + event: "W11.6: 编写 scripts/refresh_status.py 自动扫描 agents/*/profile.md 重新生成 agents/STATUS.md,支持 --dry-run,Python 3.8+ 标准库零依赖" + rating: completed current_groups: [] --- diff --git a/agents/engineer-zhao/profile.md b/agents/engineer-zhao/profile.md index e3c67fa..ec50754 100644 --- a/agents/engineer-zhao/profile.md +++ b/agents/engineer-zhao/profile.md @@ -37,5 +37,8 @@ performance_log: event: "W9.6: CLI新增/history[N]命令,含三种边界处理;/status增加history count;build 0 error 0 warning;已用batch模式验证空history和无效N场景" rating: A current_groups: - - grp-ai-plugins (待命) + - grp-cli-ux (active) --- + - date: 2026-05-27 + event: "W11.4: 实现管道输入支持(grp-cli-ux B3),pipe_mode检测_isatty→读取全部stdin→单次chat→退出;空输入返回1提示empty prompt;0 error 0 warning编译通过;4/4测试100% pass" + rating: A diff --git a/agents/qa-liu/profile.md b/agents/qa-liu/profile.md index 1057aad..30c5287 100644 --- a/agents/qa-liu/profile.md +++ b/agents/qa-liu/profile.md @@ -23,5 +23,8 @@ performance_log: - date: 2026-05-27 event: "W9.10: host_api 单元测试 (8 cases, tests/host_api_test.cpp)" rating: completed + - date: 2026-05-27 + event: "W11.3: event_bus 单元测试 (6 cases, tests/event_bus_test.cpp) + service_registry 补充测试 (6 cases, tests/service_registry_test.cpp) — 提升 core 覆盖率,补边界/生命周期 case" + rating: completed current_groups: [] --- diff --git a/agents/qa-xu/profile.md b/agents/qa-xu/profile.md index a19b150..a63f1e3 100644 --- a/agents/qa-xu/profile.md +++ b/agents/qa-xu/profile.md @@ -27,5 +27,8 @@ performance_log: - date: 2026-05-27 event: "W10.4 创建 agents/POSTMORTEM.md 项目级踩坑记录(172行),收录 PM-001~PM-005 共5条事故7条防御性规则,覆盖 stale obj / Boost.JSON 链接 / 跨DLL堆释放 / plugin_loader fail-fast / push --force 未告知" rating: completed + - date: 2026-05-27 + event: "W11.7 破坏性输入测试:build/dstalk-cli/dstalk-cli.exe (commit 004a81d) 10 场景全 PASS 零崩溃。发现 BUG-1 [CRITICAL] build/bin/ 下存在损坏副本 (MD5 d8e8c92b vs 正常 803ca2ea,命令解析全失效);BUG-2 /clear 谎报成功;BUG-3 /context 静默无输出;BUG-4 /file write 裸命令匹配失败。报告写入 agents/audits/W11.7-destructive-test.md" + rating: completed current_groups: [] --- diff --git a/dstalk-cli/src/main.cpp b/dstalk-cli/src/main.cpp index 39c76ab..af1e34d 100644 --- a/dstalk-cli/src/main.cpp +++ b/dstalk-cli/src/main.cpp @@ -349,19 +349,22 @@ int main(int argc, char* argv[]) SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING); #endif - // ---- C1: batch 模式检测 ---- + // ---- C1: batch/pipe 模式检测 ---- +#ifdef _WIN32 + bool pipe_mode = (_isatty(_fileno(stdin)) == 0); +#else + bool pipe_mode = (isatty(fileno(stdin)) == 0); +#endif bool batch_mode = false; - for (int i = 1; i < argc; ++i) { - if (std::strcmp(argv[i], "--batch") == 0) { - batch_mode = true; - break; + if (!pipe_mode) { + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--batch") == 0) { + batch_mode = true; + break; + } } } -#ifdef _WIN32 - if (!batch_mode && _isatty(_fileno(stdin)) == 0) batch_mode = true; -#else - if (!batch_mode && isatty(fileno(stdin)) == 0) batch_mode = true; -#endif + if (pipe_mode) batch_mode = true; // ---- B1: 安装 Ctrl+C 处理 ---- #ifdef _WIN32 @@ -435,6 +438,40 @@ int main(int argc, char* argv[]) std::printf("\n"); } + // ---- B3: 管道输入模式 (非交互) ---- + if (pipe_mode) { + std::string input; + char buf[4096]; + while (std::fgets(buf, sizeof(buf), stdin)) { + input += buf; + } + if (input.empty()) { + std::fprintf(stderr, "empty prompt\n"); + dstalk_shutdown(); + return 1; + } + if (!g_ai || !g_session) { + std::fprintf(stderr, CLR_RED "[ERROR] AI or session service unavailable\n" CLR_RESET); + dstalk_shutdown(); + return EXIT_SVC_UNAVAIL; + } + int history_count = 0; + const dstalk_message_t* history = g_session->history(&history_count); + dstalk_chat_result_t result = g_ai->chat(history, history_count, input.c_str(), nullptr); + if (result.ok) { + std::printf("%s\n", result.content ? result.content : ""); + g_ai->free_result(&result); + dstalk_shutdown(); + return EXIT_OK; + } else { + std::fprintf(stderr, CLR_RED "[ERROR] AI error: %s\n" CLR_RESET, + result.error ? result.error : "unknown"); + g_ai->free_result(&result); + dstalk_shutdown(); + return EXIT_AI_ERROR; + } + } + char buffer[8192]; while (true) { // B1: 检查退出标志 diff --git a/scripts/refresh_status.py b/scripts/refresh_status.py new file mode 100644 index 0000000..a0fd25a --- /dev/null +++ b/scripts/refresh_status.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +""" +Refresh agents/STATUS.md by scanning all profile.md and group files. + +Usage: + python scripts/refresh_status.py # Write agents/STATUS.md + python scripts/refresh_status.py --dry-run # Print to stdout only + +Requirements: Python 3.8+, standard library only. +Parses YAML front matter from: + - agents//profile.md (agent_id, name, role, current_groups, performance_log) + - agents/groups/grp-*.md (group_id, name, lead, members, mission, active_tasks, status) +""" + +import sys +import re +import argparse +from datetime import date +from pathlib import Path + +# Enforce UTF-8 I/O on Windows (stdout/stderr may default to cp936/gbk) +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except Exception: + pass + + +# ============================================================================= +# Path resolution +# ============================================================================= + +def _repo_root(): + """Project root (parent of this script's directory).""" + return Path(__file__).resolve().parent.parent + + +def _agents_dir(): + return _repo_root() / 'agents' + + +# ============================================================================= +# YAML front matter helpers +# ============================================================================= + +def _read_fm(filepath): + """Return front matter text between first pair of '---' lines, or None.""" + try: + text = filepath.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError) as e: + print(f"ERROR: Cannot read {filepath}: {e}", file=sys.stderr) + return None + m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL) + if not m: + print(f"WARNING: No YAML front matter in {filepath}", file=sys.stderr) + return None + return m.group(1) + + +def _fm_scalar(fm, key): + """Return value of a top-level 'key: value' line.""" + m = re.search(rf'^{key}:\s*(.+)$', fm, re.MULTILINE) + return m.group(1).strip() if m else None + + +def _fm_list(fm, key): + """Return items of a top-level YAML list (key:\\n - item1\\n - item2).""" + section = re.search(rf'^{key}:\s*\n((?: - .+\n?)*)', fm, re.MULTILINE) + if not section: + return [] + items = [] + for line in section.group(1).split('\n'): + m = re.match(r' - (.+)', line) + if m: + items.append(m.group(1).strip()) + return items + + +def _fm_performance_log(fm): + """Parse the performance_log YAML list into [{date,event,rating}, ...].""" + entries = [] + log_match = re.search(r'^performance_log:', fm, re.MULTILINE) + if not log_match: + return entries + + log_section = fm[log_match.start():] + # Each entry starts with " - date:" (indent 2, dash) + blocks = re.split(r'\n - ', log_section) + # blocks[0] = "performance_log:" header; blocks[1:] = "date:...", "event:...", ... + + for block in blocks[1:]: + date_m = re.search(r'^\s*date:\s*(.+)$', block, re.MULTILINE) + event_m = re.search(r'^\s*event:\s*["\']?([^"\'\n]+)', block, re.MULTILINE) + rating_m = re.search(r'^\s*rating:\s*(\S+)', block, re.MULTILINE) + + if date_m and event_m and rating_m: + entries.append({ + 'date': date_m.group(1).strip(), + 'event': event_m.group(1).strip(), + 'rating': rating_m.group(1).strip(), + }) + return entries + + +# ============================================================================= +# File parsers +# ============================================================================= + +def parse_profile(filepath): + """Parse a single profile.md. Returns dict or None.""" + fm = _read_fm(filepath) + if fm is None: + return None + + agent_id = _fm_scalar(fm, 'agent_id') + name = _fm_scalar(fm, 'name') + role = _fm_scalar(fm, 'role') + if not all([agent_id, name, role]): + print(f"WARNING: Missing agent_id/name/role in {filepath}", file=sys.stderr) + return None + + groups = _fm_list(fm, 'current_groups') + perf_log = _fm_performance_log(fm) + + return { + 'agent_id': agent_id, + 'name': name, + 'role': role, + 'groups_raw': groups, # raw strings from profile + 'perf_log': perf_log, + } + + +def parse_group(filepath): + """Parse a single grp-*.md. Returns dict or None.""" + fm = _read_fm(filepath) + if fm is None: + return None + + gid = _fm_scalar(fm, 'group_id') + name = _fm_scalar(fm, 'name') + lead = _fm_scalar(fm, 'lead') + mission = _fm_scalar(fm, 'mission') + members = _fm_list(fm, 'members') + active_tasks = _fm_list(fm, 'active_tasks') + explicit_status = _fm_scalar(fm, 'status') + standby = _fm_scalar(fm, 'standby') + + if not all([gid, name, lead, mission]): + print(f"WARNING: Missing required group fields in {filepath}", file=sys.stderr) + return None + + # Determine display status + if explicit_status: + display_status = explicit_status + elif standby and standby.lower() == 'true': + display_status = '待命' + elif active_tasks: + display_status = '执行中' + else: + display_status = '待命' + + return { + 'group_id': gid, + 'name': name, + 'lead': lead, + 'members': members, + 'mission': mission, + 'active_tasks': active_tasks, + 'status': display_status, + } + + +# ============================================================================= +# Agent status classification +# ============================================================================= + +def _classify(perf_log): + """ + Determine agent status and contribution from perf_log. + Returns (status, contribution_text, w_number): + status -- 'working' | 'idle' + contribution -- shortened event description + w_number -- extracted W number (e.g. 'W10.2') or '' + """ + if not perf_log: + return 'idle', '', '' + + last = perf_log[-1] + status = 'working' if last['rating'].lower() == 'ongoing' else 'idle' + + w_match = re.search(r'[Ww](\d+\.\d+|\d+)', last['event']) + w_num = f'W{w_match.group(1)}' if w_match else '' + + desc = _shorten_event(last['event']) + return status, desc, w_num + + +def _shorten_event(text, max_len=72): + """Compress an event string into a one-line description.""" + text = text.strip().strip('"').strip("'") + + # Preserve W prefix + w_prefix = '' + w_match = re.match(r'([Ww]\d+\.?\d*)', text) + if w_match: + w_prefix = w_match.group(1) + text = text[w_match.end():] + text = re.sub(r'^[::\-–\s]+', '', text) + + # Strip "完成:" + text = re.sub(r'^完成[::]\s*', '', text) + + # Truncate at sentence-ending period + if '。' in text: + text = text.split('。')[0] + + # If too long, break at a natural separator + if len(text) > max_len: + for sep in [',', ',', ';', ';', '、']: + idx = text[:max_len].rfind(sep) + if idx > max_len // 2: + text = text[:idx] + break + else: + text = text[:max_len - 3] + '...' + + text = text.strip() + if w_prefix: + return f'{w_prefix} {text}' + return text + + +# ============================================================================= +# Group membership supplement +# ============================================================================= + +def _supplement_groups(profiles, groups): + """ + For each agent, compute the union of profile current_groups and group + memberships (so the '当前小组' column is complete even when profiles + haven't been synced). + Returns a dict: agent_id -> comma-separated group_id string. + """ + # profile-level groups (strip annotations in parens) + profile_groups = {} + for p in profiles: + cleaned = [] + for g in p['groups_raw']: + gid = re.sub(r'\s*\(.*\)', '', g).strip() + if gid: + cleaned.append(gid) + profile_groups[p['agent_id']] = set(cleaned) + + # group-level reverse lookup + group_membership = {p['agent_id']: set() for p in profiles} + for g in groups: + for m in g['members']: + if m in group_membership: + group_membership[m].add(g['group_id']) + + # union + result = {} + for p in profiles: + aid = p['agent_id'] + union = profile_groups.get(aid, set()) | group_membership.get(aid, set()) + result[aid] = ', '.join(sorted(union)) if union else '--' + + return result + + +# ============================================================================= +# Wave aggregation +# ============================================================================= + +def _collect_waves(profiles): + """Collect unique W numbers from all profiles. Returns (sorted_list, max).""" + seen = set() + for p in profiles: + for entry in p['perf_log']: + for m in re.finditer(r'[Ww](\d+\.\d+|\d+)', entry['event']): + seen.add(m.group(0)) + + def _key(w): + parts = re.match(r'[Ww](\d+)\.?(\d*)', w) + major = int(parts.group(1)) if parts else 0 + minor = int(parts.group(2)) if parts and parts.group(2) else 0 + return (major, minor) + + ordered = sorted(seen, key=_key) + return ordered, ordered[-1] if ordered else 'N/A' + + +# ============================================================================= +# STATUS.md generator +# ============================================================================= + +def generate_status_md(profiles, groups): + """Build the complete STATUS.md content string.""" + today = date.today().isoformat() + n_agents = len(profiles) + n_groups = len(groups) + + # Supplement group memberships + group_col = _supplement_groups(profiles, groups) + + # Name lookup + name_map = {p['agent_id']: p['name'] for p in profiles} + + lines = [] + lines.append('# dstalk 实时编制状态') + lines.append('') + lines.append(f'> **最后更新**: {today}') + lines.append(f'> **数据来源**: 由 `scripts/refresh_status.py` 自动扫描全部 {n_agents} 个 `agents/*/profile.md` + {n_groups} 个 `agents/groups/*.md` 生成。') + lines.append('') + + # ---- Table 1 ---- + lines.append(f'## 表 1:员工状态({n_agents} 人)') + lines.append('') + lines.append('| Agent ID | 姓名 | 角色 | 最近一次贡献 | perf_log | 当前小组 | 状态 |') + lines.append('|---|---|---|---|---|---|---|') + + for p in profiles: + status, desc, _w = _classify(p['perf_log']) + contrib = desc if desc else '--' + cnt = str(len(p['perf_log'])) + groups_str = group_col.get(p['agent_id'], '--') + status_str = 'working' if status == 'working' else 'idle' + + lines.append( + f'| {p["agent_id"]} | {p["name"]} | {p["role"]} | ' + f'{contrib} | {cnt} | {groups_str} | {status_str} |' + ) + + lines.append('') + lines.append('> **状态判定规则**: 基于 `performance_log` 最后一条的 `rating`——`ongoing` 视为 `working`,其余 (`A/A+/B/completed/done/success/good`) 视为 `idle`。') + lines.append('') + + # ---- Table 2 ---- + lines.append(f'## 表 2:工作组状态({n_groups} 组)') + lines.append('') + lines.append('| group_id | 名称 | lead | members | mission | active_tasks | 状态 |') + lines.append('|---|---|---|---|---|---|---|') + + for g in groups: + lead_name = name_map.get(g['lead'], g['lead']) + member_names = ', '.join(name_map.get(m, m) for m in g['members']) + tasks = ', '.join(g['active_tasks']) if g['active_tasks'] else '--' + + lines.append( + f'| {g["group_id"]} | {g["name"]} | {lead_name} | {member_names} | ' + f'{g["mission"]} | {tasks} | {g["status"]} |' + ) + + lines.append('') + lines.append('> **成员列来源**: 以 `agents/groups/*.md` 为准(部分成员 profile 未同步更新 `current_groups`)。') + lines.append('') + + # ---- Wave Progress ---- + lines.append('## Wave 进度') + lines.append('') + all_waves, max_w = _collect_waves(profiles) + lines.append(f'**已完成高水位**: {max_w}(基于 {n_agents} 份 profile.md 的 performance_log 聚合)') + lines.append('') + if all_waves: + lines.append(f'**已发现 Wave 编号**: {", ".join(all_waves)}') + lines.append('') + + return '\n'.join(lines) + '\n' + + +# ============================================================================= +# Main +# ============================================================================= + +def main(): + parser = argparse.ArgumentParser( + description='Refresh agents/STATUS.md from profile.md and group files.' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Print output to stdout without writing STATUS.md' + ) + args = parser.parse_args() + + agents_dir = _agents_dir() + if not agents_dir.is_dir(): + print(f'ERROR: agents/ directory not found at {agents_dir}', file=sys.stderr) + sys.exit(1) + + # ---- Scan profiles ---- + profiles = [] + for child in sorted(agents_dir.iterdir()): + if not child.is_dir() or child.name.startswith('.') or child.name == 'groups': + continue + pf = child / 'profile.md' + if pf.is_file(): + parsed = parse_profile(pf) + if parsed: + profiles.append(parsed) + + if not profiles: + print('ERROR: No valid profile.md files found', file=sys.stderr) + sys.exit(1) + + # ---- Scan groups ---- + groups = [] + groups_dir = agents_dir / 'groups' + if groups_dir.is_dir(): + for gf in sorted(groups_dir.glob('grp-*.md')): + parsed = parse_group(gf) + if parsed: + groups.append(parsed) + + # ---- Generate ---- + output = generate_status_md(profiles, groups) + + if args.dry_run: + print(output) + else: + status_path = agents_dir / 'STATUS.md' + status_path.write_text(output, encoding='utf-8') + print(f'Written: {status_path} ({len(profiles)} agents, {len(groups)} groups)', + file=sys.stderr) + + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 286ba98..6f4d4e6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,3 +34,41 @@ target_link_libraries(dstalk-host-api-test ) add_test(NAME dstalk-host-api-test COMMAND dstalk-host-api-test) + +# ============================================================ +# dstalk-event-bus-test — EventBus 单元测试 +# ============================================================ + +add_executable(dstalk-event-bus-test + event_bus_test.cpp + ${CMAKE_SOURCE_DIR}/dstalk-core/src/event_bus.cpp +) + +target_include_directories(dstalk-event-bus-test + PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/src +) + +target_compile_features(dstalk-event-bus-test + PRIVATE cxx_std_17 +) + +add_test(NAME dstalk-event-bus-test COMMAND dstalk-event-bus-test) + +# ============================================================ +# dstalk-service-registry-test — ServiceRegistry 补充单元测试 +# ============================================================ + +add_executable(dstalk-service-registry-test + service_registry_test.cpp + ${CMAKE_SOURCE_DIR}/dstalk-core/src/service_registry.cpp +) + +target_include_directories(dstalk-service-registry-test + PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/src +) + +target_compile_features(dstalk-service-registry-test + PRIVATE cxx_std_17 +) + +add_test(NAME dstalk-service-registry-test COMMAND dstalk-service-registry-test) diff --git a/tests/event_bus_test.cpp b/tests/event_bus_test.cpp new file mode 100644 index 0000000..1f94e35 --- /dev/null +++ b/tests/event_bus_test.cpp @@ -0,0 +1,133 @@ +// ============================================================================ +// event_bus_test.cpp — EventBus 单元测试 +// ============================================================================ +// 测试: subscribe / unsubscribe / emit / 多订阅者 / 空总线 +// ============================================================================ + +#include +#include +#include +#include + +#include "event_bus.hpp" + +// ---- 轻量断言 ---- +static int g_failures = 0; +#define TCHECK(cond, msg) do { \ + if (cond) { \ + std::cout << "[OK] " << (msg) << "\n"; \ + } else { \ + std::cerr << "[FAIL] " << (msg) << "\n"; \ + g_failures++; \ + } \ +} while (0) + +// ============================================================ +int main() +{ + std::cout << "=== dstalk event_bus unit tests ===\n\n"; + + // ==================================================================== + // Test 1: subscribe + emit — 基本发布订阅流程 + // ==================================================================== + { + dstalk::EventBus bus; + int call_count = 0; + int received_type = 0; + + int id = bus.subscribe(42, [&](int event_type, const void* data) { + call_count++; + received_type = event_type; + }); + TCHECK(id >= 1, "subscribe returns valid subscription ID"); + + int emitted = bus.emit(42, nullptr); + TCHECK(emitted == 1, "emit returns 1 handler called"); + TCHECK(call_count == 1, "handler was invoked exactly once"); + TCHECK(received_type == 42, "handler received correct event_type"); + } + + // ==================================================================== + // Test 2: unsubscribe — 取消订阅后 handler 不再被调用 + // ==================================================================== + { + dstalk::EventBus bus; + int call_count = 0; + + int id = bus.subscribe(10, [&](int, const void*) { call_count++; }); + bus.unsubscribe(id); + + int emitted = bus.emit(10, nullptr); + TCHECK(emitted == 0, "emit after unsubscribe returns 0"); + TCHECK(call_count == 0, "unsubscribed handler was NOT called"); + } + + // ==================================================================== + // Test 3: 多订阅者 — 同一事件多个 handler 按订阅顺序全部调用 + // ==================================================================== + { + dstalk::EventBus bus; + std::vector order; + + bus.subscribe(1, [&](int, const void*) { order.push_back(1); }); + bus.subscribe(1, [&](int, const void*) { order.push_back(2); }); + bus.subscribe(1, [&](int, const void*) { order.push_back(3); }); + + int emitted = bus.emit(1, nullptr); + TCHECK(emitted == 3, "emit returns 3 handlers called"); + TCHECK(order.size() == 3, "all 3 handlers invoked"); + + // 验证订阅顺序 (FIFO: 按 subscribe 顺序触发) + bool ordered = (order[0] == 1 && order[1] == 2 && order[2] == 3); + TCHECK(ordered, "handlers invoked in subscription order (1,2,3)"); + } + + // ==================================================================== + // Test 4: 空总线 emit 不崩溃,返回 0 + // ==================================================================== + { + dstalk::EventBus bus; + int emitted = bus.emit(99, nullptr); + TCHECK(emitted == 0, "emit on empty bus returns 0 (no crash)"); + } + + // ==================================================================== + // Test 5: 不同 event_type 独立分发 — 只触发匹配的 handler + // ==================================================================== + { + dstalk::EventBus bus; + int count_a = 0, count_b = 0; + + bus.subscribe(100, [&](int, const void*) { count_a++; }); + bus.subscribe(200, [&](int, const void*) { count_b++; }); + + bus.emit(100, nullptr); + TCHECK(count_a == 1 && count_b == 0, + "emit type=100 only triggers type-100 handler"); + + bus.emit(200, nullptr); + TCHECK(count_a == 1 && count_b == 1, + "emit type=200 only triggers type-200 handler"); + } + + // ==================================================================== + // Test 6: 退订不存在的 ID 不崩溃 + // ==================================================================== + { + dstalk::EventBus bus; + bus.unsubscribe(99999); // 不存在的 ID + std::cout << "[OK] unsubscribe non-existent ID (99999) did not crash\n"; + } + + // ==================================================================== + // 结果 + // ==================================================================== + std::cout << "\n"; + if (g_failures == 0) { + std::cout << "=== All event_bus tests passed ===\n"; + return 0; + } else { + std::cerr << "=== " << g_failures << " event_bus test(s) FAILED ===\n"; + return 1; + } +} diff --git a/tests/service_registry_test.cpp b/tests/service_registry_test.cpp new file mode 100644 index 0000000..081c038 --- /dev/null +++ b/tests/service_registry_test.cpp @@ -0,0 +1,114 @@ +// ============================================================================ +// service_registry_test.cpp — ServiceRegistry 单元测试(补充覆盖,不与 host_api_test 重叠) +// ============================================================================ +// host_api_test 已覆盖: 重复注册(同名同版/同名异版)、查询不存在服务、版本不满足、 +// shutdown 后查询。本测试补充边界与生命周期路径。 +// ============================================================================ + +#include +#include + +#include "service_registry.hpp" + +// ---- 轻量断言 ---- +static int g_failures = 0; +#define TCHECK(cond, msg) do { \ + if (cond) { \ + std::cout << "[OK] " << (msg) << "\n"; \ + } else { \ + std::cerr << "[FAIL] " << (msg) << "\n"; \ + g_failures++; \ + } \ +} while (0) + +// ============================================================ +int main() +{ + std::cout << "=== dstalk service_registry unit tests (supplement) ===\n\n"; + + // ==================================================================== + // Test 1: register_service(nullptr name) → -1 + // ==================================================================== + { + dstalk::ServiceRegistry reg; + void* vt = reinterpret_cast(0x10); + int r = reg.register_service(nullptr, 1, vt); + TCHECK(r == -1, "register_service(nullptr name) returns -1"); + } + + // ==================================================================== + // Test 2: register_service(nullptr vtable) → -1 + // ==================================================================== + { + dstalk::ServiceRegistry reg; + int r = reg.register_service("valid_name", 1, nullptr); + TCHECK(r == -1, "register_service(nullptr vtable) returns -1"); + } + + // ==================================================================== + // Test 3: 完整生命周期 — register → query → unregister → query(nullptr) + // ==================================================================== + { + dstalk::ServiceRegistry reg; + void* vt = reinterpret_cast(0xDEAD); + + int r = reg.register_service("life", 3, vt); + TCHECK(r == 0, "register_service(\"life\",3) returns 0"); + + void* q1 = reg.query_service("life", 2); + TCHECK(q1 == vt, "query_service(\"life\",2) returns vtable after register"); + + reg.unregister_service("life"); + + void* q2 = reg.query_service("life", 1); + TCHECK(q2 == nullptr, "query_service(\"life\",1) returns nullptr after unregister"); + } + + // ==================================================================== + // Test 4: unregister_service(nullptr name) 不崩溃(安全空操作) + // ==================================================================== + { + dstalk::ServiceRegistry reg; + reg.unregister_service(nullptr); + std::cout << "[OK] unregister_service(nullptr) did not crash\n"; + } + + // ==================================================================== + // Test 5: 注册后重新注册同名 → 先 unregister 再 register 成功 + // ==================================================================== + { + dstalk::ServiceRegistry reg; + void* vt1 = reinterpret_cast(0xA); + void* vt2 = reinterpret_cast(0xB); + + reg.register_service("reborn", 1, vt1); + reg.unregister_service("reborn"); + + int r = reg.register_service("reborn", 2, vt2); + TCHECK(r == 0, "register_service after unregister same name returns 0"); + + void* q = reg.query_service("reborn", 2); + TCHECK(q == vt2, "query_service after re-register returns new vtable"); + } + + // ==================================================================== + // Test 6: query_service(nullptr name) → nullptr + // ==================================================================== + { + dstalk::ServiceRegistry reg; + void* q = reg.query_service(nullptr, 1); + TCHECK(q == nullptr, "query_service(nullptr name) returns nullptr"); + } + + // ==================================================================== + // 结果 + // ==================================================================== + std::cout << "\n"; + if (g_failures == 0) { + std::cout << "=== All service_registry tests passed ===\n"; + return 0; + } else { + std::cerr << "=== " << g_failures << " service_registry test(s) FAILED ===\n"; + return 1; + } +}