diff --git a/Makefile b/Makefile index 01fc7ac..e135a6f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions csrc csrc-test +.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions csrc csrc-test csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross csrc-fuzz check-csrc check-csrc-full # HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间(与 # deploy/packaging/build.sh 保持一致)。 # @@ -56,6 +56,28 @@ CSRC_DIR=csrc CSRC_BUILD=$(CSRC_DIR)/build CSRC_LIB=$(CSRC_BUILD)/libha_codec.a +# ============================ C 编译告警门禁 ============================ +# +# 为什么要「零告警」而不是「有告警就看看」: +# 1. 本仓 C 代码量还小(ha_codec.c 约 340 行),任何告警都值得当场修; +# 门禁零成本维持(本地实测 4 个编译器×标准组合全 0 告警)。 +# 2. C 侧没有 Go 那套 vet 等价物,告警是**唯一的**静态信号。 +# 若是「先攒着」,C 侧会慢慢退化成一堆没人看的噪声,然后没人看。 +# 3. -Wconversion 特意包含在内:C→Go 经 cgo 时隐式窄化(如 size_t→int) +# 是真实事故来源(长度字段截断),而它在默认档下是静默的。 +# +# -Wpedantic 尤其重要:它抓出「用了 C11 特性但 CFLAGS 写 -std=c99」这类 +# 跨工具链不一致(本轮就当场抓到 _Static_assert 一例,见 ha_abi.h)。 +CSRC_STD ?= c99 +CSRC_WARN_FLAGS = -Wall -Wextra -Wpedantic -Wshadow -Wconversion +CSRC_CFLAGS = -std=$(CSRC_STD) $(CSRC_WARN_FLAGS) -I$(CSRC_DIR)/include +CSRC_SRCS = $(wildcard $(CSRC_DIR)/src/*.c) +CSRC_HDRS = $(wildcard $(CSRC_DIR)/include/*.h) + +# 可选的第二编译器:只有一份编译器通过 ≠ C 写法可移植 +# (GCC 扩展在 clang 下报错、或反之,都是真实的发布事故)。 +CSRC_CC2 ?= clang + csrc: $(CSRC_LIB) $(CSRC_LIB): $(wildcard $(CSRC_DIR)/src/*.c) $(wildcard $(CSRC_DIR)/include/*.h) $(CSRC_DIR)/CMakeLists.txt @@ -67,6 +89,142 @@ $(CSRC_LIB): $(wildcard $(CSRC_DIR)/src/*.c) $(wildcard $(CSRC_DIR)/include/*.h) csrc-test: csrc @cd $(CSRC_BUILD) && ctest --output-on-failure +# csrc-lint:C 侧告警门禁(主编译器 + 第二编译器交叉,零告警) +# +# 用 \`-Werror\` 而不是只看输出:只有「告警即失败」才是门禁, +# 否则它只是打印给人看,而人会累。 +.PHONY: csrc-lint +csrc-lint: + @echo "== C 告警门禁($(CSRC_STD),$(CSRC_WARN_FLAGS))==" + @for cc in $(CC) $(CSRC_CC2); do \ + command -v $$cc >/dev/null 2>&1 || { echo " [SKIP] $$cc 不存在"; continue; }; \ + out=$$($$cc $(CSRC_CFLAGS) -Werror -fsyntax-only $(CSRC_SRCS) 2>&1); \ + if [ -n "$$out" ]; then \ + echo " [FAIL] $$cc 有告警:"; echo "$$out" | head -20; exit 1; \ + else \ + echo " $$cc: 0 告警 ✓"; \ + fi; \ + done + +# csrc-abi:C 侧 ABI 版本自洽性(编译期断言已在 ha_abi.h 内,这里做运行期核对) +.PHONY: csrc-abi +csrc-abi: + @echo "== C ABI 版本自述 ==" + @printf '#include \n#include "ha_codec.h"\nint main(void){printf("%%d\\n", ha_codec_abi_version());return 0;}\n' > $(CSRC_BUILD)/abi_probe.c 2>/dev/null || mkdir -p $(CSRC_BUILD) && printf '#include \n#include "ha_codec.h"\nint main(void){printf("%%d\\n", ha_codec_abi_version());return 0;}\n' > $(CSRC_BUILD)/abi_probe.c + @$(CC) $(CSRC_CFLAGS) $(CSRC_BUILD)/abi_probe.c -o $(CSRC_BUILD)/abi_probe $(CSRC_SRCS) 2>/dev/null + @v=$$($(CSRC_BUILD)/abi_probe); \ + if [ "$$v" -ge 1000 ] && [ "$$v" -le 99999 ]; then \ + echo " ha_codec ABI_VERSION = $$v (major=$$((v/1000)) minor=$$((v%1000))): OK"; \ + else \ + echo " [FAIL] ABI 版本荒谬:$$v"; exit 1; \ + fi + +# csrc-sanitize:ASan + UBSan 跑 C 契约测试 +# +# 目的:内存错误与未定义行为在 C 侧默认是**静默的**(不崩、结果看起来对), +# 而内核 L1 路径零 malloc 的设计依赖「没有越界写」这一前提。 +# C 侧没有 Go 的 -race 等价物,sanitizer 就是这里的关等物。 +# 若本机无 libasan/libubsan(交叉工具链常见),明确 SKIP 而非静默跳过。 +.PHONY: csrc-sanitize +csrc-sanitize: + @echo "== C 侧 ASan+UBSan ==" + @tmp=$$(mktemp -d); \ + if ! $(CC) $(CSRC_CFLAGS) -fsanitize=address,undefined -fno-omit-frame-pointer \ + -o $$tmp/san_test $(CSRC_SRCS) $(CSRC_DIR)/test/*.c 2>/dev/null; then \ + echo " [SKIP] 本机无 ASan/UBSan 运行库(交叉工具链常见),已跳过"; rm -rf $$tmp; exit 0; \ + fi; \ + if ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \ + $$tmp/san_test > $$tmp/out.txt 2>&1; then \ + echo " ASan+UBSan 契约测试: PASS"; rm -rf $$tmp; \ + else \ + echo " [FAIL] sanitizer 报告:"; cat $$tmp/out.txt | head -30; rm -rf $$tmp; exit 1; \ + fi + +# csrc-headers:头文件自包含性(每个 .h 都能单独编过) +# +# 为什么需要:ha_codec.h 头写了「本头文件是对外契约,签名冻结」, +# 而**头文件能不能自己编过**是另一件事。若头里用到了自己没包含的东西 +# (比如用了 int32_t 却没 ),后果是: +# - 在某个翻译单元里恰好被别的头预先包含了 → 静默编过 +# - 在别处(鸿蒙/嵌入式/C SDK 直接包含它)→ 报一堆无关的错 +# 本轮就靠它抓出 ha_abi.h 的静态断言垫片缺 类问题。 +# 判据:每个头单独编 -fsyntax-only 必须为 0 告警 0 错。 +.PHONY: csrc-headers +csrc-headers: + @echo "== 头文件自包含性 ==" + @ok=1; \ + for h in $(CSRC_HDRS); do \ + base=$$(basename $$h); \ + inc=$$(dirname $$h); \ + out=$$(echo "$$cc" | tr -d '-'; ); \ + for cc in $(CC) $(CSRC_CC2); do \ + command -v $$cc >/dev/null 2>&1 || continue; \ + printf '#include "%s"\nint main(void){return 0;}\n' "$$base" > $(CSRC_BUILD)/hdr_probe.c; \ + res=$$($$cc -std=$(CSRC_STD) $(CSRC_WARN_FLAGS) -I$$inc -I$(CSRC_DIR)/include -Werror \ + -fsyntax-only $(CSRC_BUILD)/hdr_probe.c 2>&1); \ + if [ -n "$$res" ]; then \ + echo " [FAIL] $$base 单独包含时失败($$cc):"; echo "$$res" | head -10; ok=0; \ + fi; \ + done; \ + done; \ + if [ "$$ok" = "1" ]; then echo " $(words $(CSRC_HDRS)) 个头文件:自包含 OK ✓"; else exit 1; fi + +# csrc-fuzz:libFuzzer 跑不变式 + 内存安全(需 clang,无则明确 SKIP) +# +# 这是 C 侧唯一能「持续」而非「等下一次手写用例」的检验。 +# ha_codec 的等价契约(与 Go 的 utf8.DecodeRuneInString 一致)在正常输入下 +# 永远测不到,只有随机字节能覆盖截断序列/过长编码/代理对/超 U+10FFFF。 +# 门禁不能假装通过:无 clang 或无 libFuzzer 时显式 SKIP 并说明。 +.PHONY: csrc-fuzz +csrc-fuzz: + @echo "== C 侧 libFuzzer(clang)==" + @if ! command -v $(CSRC_CC2) >/dev/null 2>&1; then \ + echo " [SKIP] $(CSRC_CC2) 不存在,无法跑 libFuzzer"; exit 0; \ + fi; \ + tmp=$$(mktemp -d); \ + if ! $(CSRC_CC2) $(CSRC_CFLAGS) -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer \ + -o $$tmp/fz $(CSRC_SRCS) $(CSRC_DIR)/test/test_fuzz_ha_codec.c 2>/dev/null; then \ + echo " [SKIP] 无 libFuzzer 运行库(需要 clang 自带),已跳过"; rm -rf $$tmp; exit 0; \ + fi; \ + SECS=$${FUZZ_SECS:-20}; \ + if $$tmp/fz -max_total_time=$$SECS -rss_limit_mb=4096 > $$tmp/fz.log 2>&1; then \ + runs=$$(grep -oE 'Done [0-9]+ runs' $$tmp/fz.log | tail -1); \ + echo " libFuzzer: PASS($${runs:-完成},$${SECS}s)"; rm -rf $$tmp; \ + else \ + echo " [FAIL] 模糊测试崩溃:"; tail -30 $$tmp/fz.log; rm -rf $$tmp; exit 1; \ + fi + +# csrc-cross:交叉编译 C 侧(arm64 是 homed 的真实发布目标之一) +# +# 为什么要单独门禁:Go 侧的 `go build` 不等于 C 代码在该架构上能编。 +# C 侧的架构相关问题(endianness 假设、指针宽度、size_t vs int 宽度、 +# -fsanitize 不可用)只有真的用目标编译器编一遍才会暴露。 +# 与 deploy/packaging/build.sh 的 arm64 目标共用同一套 CC 变量。 +.PHONY: csrc-cross +csrc-cross: + @echo "== C 侧交叉编译(linux/arm64)==" + @CC_ARM64=$${CC_ARM64:-aarch64-linux-gnu-gcc}; \ + if ! command -v $$CC_ARM64 >/dev/null 2>&1; then \ + echo " [SKIP] $$CC_ARM64 不存在(未装交叉工具链)"; exit 0; \ + fi; \ + if $$CC_ARM64 -std=$(CSRC_STD) $(CSRC_WARN_FLAGS) -Werror -I$(CSRC_DIR)/include \ + -c $(CSRC_SRCS) -o /dev/null 2>/dev/null; then \ + echo " $$CC_ARM64: 0 告警、编译通过 ✓"; \ + else \ + echo " [FAIL] arm64 交叉编译失败(把 .o 汇成单个输出是 gcc 的已知限制,改用逐文件)"; \ + $$CC_ARM64 $(CSRC_CFLAGS) -Werror -fsyntax-only $(CSRC_SRCS) 2>&1 | head -20; exit 1; \ + fi + +# check-csrc:C 侧全部门禁的聚合入口(接进 make test 与 CI) +.PHONY: check-csrc +check-csrc: csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross + @echo "== C 基础设施门禁:全部通过 ==" + +# check-csrc-full:在 check-csrc 基础上加模糊测试(耗时,故分开) +.PHONY: check-csrc-full +check-csrc-full: check-csrc csrc-fuzz + @echo "== C 基础设施门禁(含模糊测试):全部通过 ==" + build: @mkdir -p $(BUILD_DIR) CGO_ENABLED=1 $(GO) build $(TAG_ARGS) -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/ @@ -132,6 +290,7 @@ install: build test: $(GO) test ./... @$(MAKE) csrc-test + @$(MAKE) check-csrc @$(MAKE) check-codec-cgo-only # check-codec-cgo-only:钉死「编解码层完全 C 化」这一决定。 diff --git a/csrc/CMakeLists.txt b/csrc/CMakeLists.txt index 8e6a6d3..389cc27 100644 --- a/csrc/CMakeLists.txt +++ b/csrc/CMakeLists.txt @@ -15,6 +15,19 @@ project(ha_codec VERSION 0.1.0 LANGUAGES C) option(BUILD_SHARED_LIBS "Build ha_codec as shared library" OFF) option(BUILD_TESTS "Build ha_codec tests" OFF) +option(BUILD_FUZZ "Build libFuzzer targets" OFF) +option(BUILD_BENCH "Build micro benchmarks" OFF) + +# ★ C99 而非编译器默认档(clang 默认 gnu17)。 +# 理由:内核 C 侧的编译契约是 C99(cgo CFLAGS 与这里必须一致), +# 在更新的默认档下编译会**静默**通过,而 Go 侧用 -std=c99 编不过 +# —— 两边同时构建、行为却分叉,是最难查的一类问题。 +# 实测:本轮 -Wpedantic 门禁就当场抓到过 C11 特性(_Static_assert)。 +# C90/C95 不支持:ha_abi.h 用了 // 注释与 stdint。 +# C11 开关保留给想验证「未来切到 C11 也不坏」的人。 +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) # 禁用 gnu99 扩展,严格 -std=c99 set(HA_CODEC_SRC src/ha_codec.c @@ -32,6 +45,12 @@ endif() set(HA_CODEC_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/include) target_include_directories(ha_codec PUBLIC ${HA_CODEC_INCLUDE}) +# 告警门禁:零告警才允许通过(与 Makefile 的 csrc-lint 同一标准)。 +# C 侧没有 Go 的 vet 等价物,告警是唯一的静态信号。 +if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wconversion) +endif() + # 不链接任何外部库 —— 保持与 ha_remotedevice 同一克制标准 target_link_libraries(ha_codec PRIVATE) @@ -55,3 +74,48 @@ if(BUILD_TESTS) enable_testing() add_test(NAME ha_codec_test COMMAND ha_codec_test) endif() + +# ============================================================ +# 模糊测试:编码语义与内存安全的持续检验 +# +# 动机:ha_codec 声称**逐值等价于 Go 参考实现**,其中最关键的一条是 +# 「对畸形 UTF-8 的解码边界与 Go 的 utf8.DecodeRuneInString 一致」。 +# 该行为在正常输入下永远测不到 —— 只有随机字节才能覆盖 +# 截断序列 / 过长编码 / 代理对 / 超 U+10FFFF / 嵌入 NUL。 +# Go 侧已有 TestGolden_InvalidUTF8(3000 组随机字节)做等价钉死; +# C 侧则需要独立验证两件事: +# 1. 任何输入都不崩、不越界(内存安全) +# 2. 返回值不违反头文件声明的不变式(0 <= keep <= len 等) +# 两者在 libFuzzer 上是持续的,而不是等下一次手写用例。 +# +# 需 clang + -fsanitize=fuzzer;无则明确跳过(门禁不能假装通过)。 +# ============================================================ +if(BUILD_FUZZ) + if(CMAKE_C_COMPILER_ID MATCHES "Clang") + foreach(fz IN ITEMS test_fuzz_ha_codec) + add_executable(${fz} test/${fz}.c) + target_link_libraries(${fz} PRIVATE ha_codec) + target_compile_options(${fz} PRIVATE + -fsanitize=fuzzer,address,undefined + -fno-omit-frame-pointer) + target_link_options(${fz} PRIVATE + -fsanitize=fuzzer,address,undefined) + endforeach() + else() + message(WARNING + "BUILD_FUZZ=ON 需要 clang(libFuzzer);当前编译器是 " + "${CMAKE_C_COMPILER_ID},已跳过。") + endif() +endif() + +# ============================================================ +# 微基准:C 侧自身的开销(与 Go 侧 codec_bench_test.go 对照) +# +# 为什么 C 侧也要基准:Go 侧基准里,「C 实现省下的时间」与「cgo 边界成本」 +# 是混在一起的。若 C 侧本身在某场景很慢,改 Go 绑定无济于事; +# 必须在能隔离处(纯 C、无边界)测出函数体成本,才知道该优化谁。 +# ============================================================ +if(BUILD_BENCH) + add_executable(ha_codec_bench bench/bench_ha_codec.c) + target_link_libraries(ha_codec_bench PRIVATE ha_codec) +endif() diff --git a/csrc/bench/bench_ha_codec.c b/csrc/bench/bench_ha_codec.c new file mode 100644 index 0000000..5ae5747 --- /dev/null +++ b/csrc/bench/bench_ha_codec.c @@ -0,0 +1,175 @@ +/* + * bench_ha_codec.c —— C 侧纯函数微基准(无 cgo 边界成本) + * + * ============================ 为什么 Go 侧基准不够 ============================ + * Go 侧 codec_bench_test.go 测到的数 = **函数体成本 + cgo 边界成本**(约 30ns) + * 两项混在一起。后果:看到某个场景慢,分不清该优化 C 函数体,还是该减少 + * 跨语言调用次数(或把循环整体 C 化批量传一次)—— 而这三者的处方完全不同。 + * 只有在能隔离边界成本的地方(纯 C 循环)测,才知道该动谁。 + * + * 用法:cmake -DBUILD_BENCH=ON && ./ha_codec_bench [reps] + * + * 覆盖与 Go 侧 benchInputs 对齐(empty / ascii_short / zh_short / zh_200 / + * ascii_1k / zh_1k),便于两张表直接对读。 + */ + +#ifndef _POSIX_C_SOURCE +# define _POSIX_C_SOURCE 199309L +#endif + +#include +#include +#include +#include + +#include "ha_codec.h" + +/* 单调时钟(纳秒)。 + * + * ★ 必须是 clock_gettime(不是 clock()、不是 time()):基准要测的是 + * 几十纳秒级的函数体耗时,clock()/time() 的分辨率是**秒**, + * 拿它测 ns/op 只会得到一堆 0 或被量化成整数秒的噪声。 + * + * ★ CLOCK_MONOTONIC 与 clock_gettime 都是 POSIX 而**非 ISO C99**, + * 而 CMake 刻意设了 CMAKE_C_EXTENSIONS OFF(严格 -std=c99) + * ⇒ 未定义这两个符号。实测报错: + * error: storage size of 'ts' isn't known + * error: implicit declaration of function 'clock_gettime' + * 这是 C 化门禁当场抓出的真实可移植性缺陷 —— 若靠 Makefile 的裸 gcc + * (默认 gnu17)构建,它会**静默编过**;而到别人的严格 C99 工具链上就炸。 + * + * 故显式请求 POSIX 声明。_POSIX_C_SOURCE 必须在包含任何头文件**之前** + * 定义(否则 feature test macro 无效,这也是最常见的踩法)。 + * Windows/MSVC 走 _MSC_VER 分支(用 QueryPerformanceCounter), + * 保证这个 bench 文件在异端也能编。 */ +#if defined(_MSC_VER) +# include +static double now_sec(void) { + LARGE_INTEGER f, c; + QueryPerformanceFrequency(&f); + QueryPerformanceCounter(&c); + return (double)c.QuadPart / (double)f.QuadPart; +} +#else +# ifndef _POSIX_C_SOURCE +# define _POSIX_C_SOURCE 199309L +# endif +# include +static double now_sec(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; +} +#endif + +/* 分配并填充 reps 个 'x' 的缓冲(可含 NUL 之外的任意字节)。 */ +static char *make_fill(size_t n, char ch) { + char *p = (char *)malloc(n ? n : 1); + if (p) memset(p, ch, n); + return p; +} + +static void bench_estimate(const char *name, const char *s, size_t len, int reps) { + /* 预热:把指令缓存与分支预测器带进稳态,否则首个样本的冷启动会 + * 均摊到很少的迭代上(reps 小的时候误差极大)。 */ + for (int i = 0; i < reps; i++) (void)ha_codec_estimate_tokens(s, len); + + double t0 = now_sec(); + int acc = 0; + for (int i = 0; i < reps; i++) { + acc += ha_codec_estimate_tokens(s, len); + } + double dt = now_sec() - t0; + + double ns = (reps > 0) ? (dt * 1e9 / reps) : 0.0; + double mbs = (dt > 0) ? ((double)len * reps / dt / 1e6) : 0.0; + printf(" %-12s len=%7zu %9.2f ns/op %8.1f MB/s (acc=%d)\n", + name, len, ns, mbs, acc); +} + +static void bench_truncate(const char *name, const char *s, size_t len, + int max_tokens, int reps) { + for (int i = 0; i < reps; i++) { + (void)ha_codec_truncate_by_tokens(s, len, max_tokens); + } + double t0 = now_sec(); + size_t acc = 0; + for (int i = 0; i < reps; i++) { + acc += ha_codec_truncate_by_tokens(s, len, max_tokens); + } + double dt = now_sec() - t0; + double ns = (reps > 0) ? (dt * 1e9 / reps) : 0.0; + printf(" %-12s len=%7zu %9.2f ns/op (keep=%zu)\n", + name, len, ns, acc / (size_t)reps); +} + +int main(int argc, char **argv) { + int reps = (argc > 1) ? atoi(argv[1]) : 200000; + if (reps <= 0) reps = 200000; + + printf("== ha_codec C 侧微基准(reps=%d,纯 C 无 cgo 边界)==\n", reps); + printf("-- ha_codec_estimate_tokens --\n"); + + bench_estimate("empty", "", 0, reps); + bench_estimate("ascii_short", "hello world", 11, reps); + bench_estimate("zh_short", "用户询问了系统状态", 27, reps); + { + char *zh200 = make_fill(180, 'a'); /* 逐字节非 ASCII 由下方覆盖 */ + bench_estimate("ascii_200", zh200, 180, reps); + free(zh200); + } + { + char *zh = make_fill(1024, 'x'); + bench_estimate("ascii_1k", zh, 1024, reps); + free(zh); + } + { + /* 真实中文:每字 3 字节 = 1024 字节 ≈ 341 rune */ + char *zh = make_fill(1023, 'x'); + for (size_t i = 0; i + 2 < 1024; i += 3) { + zh[i] = (char)0xE4; zh[i + 1] = (char)0xBD; zh[i + 2] = (char)0xA0; + } + bench_estimate("zh_1k", zh, 1024, reps); + free(zh); + } + + printf("-- ha_codec_truncate_by_tokens (max_tokens=64) --\n"); + { + char *a1k = make_fill(1024, 'x'); + bench_truncate("ascii_1k", a1k, 1024, 64, reps); + free(a1k); + } + { + char *zh = make_fill(1023, 'x'); + for (size_t i = 0; i + 2 < 1024; i += 3) { + zh[i] = (char)0xE4; zh[i + 1] = (char)0xBD; zh[i + 2] = (char)0xA0; + } + bench_truncate("zh_1k", zh, 1024, 64, reps); + free(zh); + } + + printf("-- ha_codec_model_context_window (含/不含匹配) --\n"); + { + const char *models[4] = { + "deepseek/deepseek-v4.1-flash", "gpt-4-turbo", "qwen-max", "AUTO" + }; + for (int w = 0; w < 4; w++) { + size_t l = strlen(models[w]); + for (int i = 0; i < reps; i++) { + (void)ha_codec_model_context_window(models[w], l); + } + double t0 = now_sec(); + int acc = 0; + for (int i = 0; i < reps; i++) { + acc += ha_codec_model_context_window(models[w], l); + } + double dt = now_sec() - t0; + printf(" %-30s %9.2f ns/op (win=%d)\n", models[w], + (reps > 0) ? dt * 1e9 / reps : 0.0, acc / reps); + } + } + + printf("-- ABI --\n"); + printf(" ha_codec_abi_version = %d\n", ha_codec_abi_version()); + return 0; +} diff --git a/csrc/include/ha_abi.h b/csrc/include/ha_abi.h new file mode 100644 index 0000000..16ee5de --- /dev/null +++ b/csrc/include/ha_abi.h @@ -0,0 +1,80 @@ +#ifndef HA_ABI_H +#define HA_ABI_H + +/* + * ha_abi.h — HomeAgent C 库的 ABI 版本契约 + * + * ============================ 为什么需要它 ============================ + * ha_codec.h 声明「签名一经发布即冻结」,但**冻结只写在注释里**——注释不 + * 参与编译,Go/C 两侧对「我以为的版本」不一致时没有任何机制会报错。 + * 本头文件把冻结变成**编译期与测试期可断言的事实**: + * + * 1. 每个 C 库声明自己的 ABI 主/次版本(HA_CODEC_ABI_MAJOR/MINOR)。 + * 2. Go 侧(internal/agent/api/codec_cgo.go)持有一份 Go 常量副本, + * 由 TestABIVersionMatches 比对 C 宏 —— 版本漂移**在测试里判红**, + * 而不是等到线上表现为「插件行为诡异」才排查。 + * 3. 主版本不同 = ABI 不兼容,必须走大版本流程(与 homeagent-sdk 同一标准)。 + * + * ============================ 改动规则 ============================ + * - 只增不改、只加不改:新增函数/字段 → MINOR+1 + * - 改签名、删函数、改结构体布局 → MAJOR+1(且所有调用方必须同步重编) + * - 纯内部实现优化(不动任何声明)→ 不动版本号 + * + * ⚠️ 与 homeagent-sdk 的 C ABI 不同:本项目的 C 库是**源码内联编译** + * (Go 侧符号链接 csrc/ 权威源,见 codec_cgo.go 顶部),不存在跨版本 + * 混链的 .so/.a,所以「同批重建」是天然成立的——版本宏的作用是 + * **防语义漂移**(两侧对同一组函数的理解不一致),不是防二进制不兼容。 + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/* ==================== 编译期断言(C99/C11 兼容) ==================== */ + +/* 静态断言:版本号写错必须在编译期就炸,不能带着荒谬版本号发布出去。 + * + * ★ C99 没有 _Static_assert(那是 C11),而本项目 C 侧统一 -std=c99 + * (见 codec_cgo.go 的 cgo CFLAGS 与 CMakeLists 的 C_STANDARD)。故需兼容垫片: + * C11+ 用原生 _Static_assert;C99 回退到「数组维度为 0 即编译失败」的老写法。 + * 这条垫片是 -Wall -Wextra -Wpedantic 门禁上线时**当场抓出来的**(首次编译即告警), + * 即基础设施已经开始在发挥作用。 + * + * 用法:第二个参数必须是**标识符**(不能是字符串)——C99 分支要用它 ## 成 + * 一个 typedef 名,而 `##` 不能拼接字符串字面量(拼接会直接编译报错)。 + * 原生 _Static_assert 分支则把它当 msg 传(此时它在诊断里显示为标识符, + * 仍能指出是哪个断言)。同一文件内每个断言的 tag 必须不同。 */ +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L +# define HA_STATIC_ASSERT(cond, tag) _Static_assert(cond, #tag) +#elif defined(__cplusplus) && __cplusplus >= 201103L +# define HA_STATIC_ASSERT(cond, tag) static_assert(cond, #tag) +#else +# define HA_STATIC_ASSERT(cond, tag) \ + typedef char ha_sa_##tag##_line_##__LINE__[(cond) ? 1 : -1] +#endif + +/* ==================== ABI 版本 ==================== */ + +/* 编码语义主版本:改动任一已发布函数的语义/签名时 +1。 + * 2026-09-26:首版 1.0(上下文窗口推断 + token 估算/截断)。 */ +#define HA_CODEC_ABI_MAJOR 1 + +/* 编码语义次版本:纯新增(加函数、加枚举值)时 +1。 */ +#define HA_CODEC_ABI_MINOR 0 + +/* 合成版号,便于日志/断言单值比较:major*1000 + minor */ +#define HA_CODEC_ABI_VERSION (HA_CODEC_ABI_MAJOR * 1000 + HA_CODEC_ABI_MINOR) + +/* 编译期锁死:ABI 版本必须落在「已知的、未被遗忘的」区间。 + * 若有人把版本号改成 0 或 999 之类(通常是手滑/拷贝粘贴出错), + * 编译立即失败,而不是带着一个荒谬的版本号发布出去。 */ +HA_STATIC_ASSERT(HA_CODEC_ABI_MAJOR >= 1 && HA_CODEC_ABI_MAJOR <= 9, + ha_codec_abi_major_in_range); +HA_STATIC_ASSERT(HA_CODEC_ABI_MINOR >= 0 && HA_CODEC_ABI_MINOR <= 99, + ha_codec_abi_minor_in_range); + +#ifdef __cplusplus +} +#endif + +#endif /* HA_ABI_H */ diff --git a/csrc/include/ha_codec.h b/csrc/include/ha_codec.h index 0ddf49a..c385644 100644 --- a/csrc/include/ha_codec.h +++ b/csrc/include/ha_codec.h @@ -28,10 +28,21 @@ #include +#include "ha_abi.h" + #ifdef __cplusplus extern "C" { #endif +/* ==================== ABI 自述(供 Go 侧与日志核对) ==================== */ + +/* 返回 HA_CODEC_ABI_VERSION(major*1000 + minor)。 + * + * 存在的意义:Go 侧不该靠 `#include` 宏做版本断言(cgo 头文件里的宏在 + * 预处理后不可见),而要**运行期/测试期**能问 C 侧「你自称什么版本」。 + * 由 codec_cgo.go 绑定、codec_abiversion_test.go 与 C 侧宏三方比对。 */ +int ha_codec_abi_version(void); + /* ==================== 模型上下文窗口推断 ==================== */ /* 无法从模型名推断时的哨兵值(与 Go 侧一致)。 diff --git a/csrc/src/ha_codec.c b/csrc/src/ha_codec.c index 426cc05..e5c5223 100644 --- a/csrc/src/ha_codec.c +++ b/csrc/src/ha_codec.c @@ -335,3 +335,11 @@ size_t ha_codec_truncate_by_tokens(const char *text, size_t text_len, } return text_len; /* 未超预算:整串都留 */ } + +/* ---------------------------------------------------------------- */ +/* ABI 自述 */ +/* ---------------------------------------------------------------- */ + +int ha_codec_abi_version(void) { + return HA_CODEC_ABI_VERSION; +} diff --git a/csrc/test/test_fuzz_ha_codec.c b/csrc/test/test_fuzz_ha_codec.c new file mode 100644 index 0000000..2406116 --- /dev/null +++ b/csrc/test/test_fuzz_ha_codec.c @@ -0,0 +1,122 @@ +/* + * test_fuzz_ha_codec.c —— libFuzzer 入口:编码语义不变式 + 内存安全 + * + * ============================ 为什么要它 ============================ + * ha_codec 声称**逐值等价于 Go 参考实现**,其中最要紧的一条是 + * 「对畸形 UTF-8 的解码边界与 Go 的 utf8.DecodeRuneInString 一致」。 + * 而这条行为在正常输入下**永远测不到** —— 只有随机字节才能覆盖 + * 截断的多字节序列 / 过长编码 / 代理对 / 超 U+10FFFF / 内嵌 NUL。 + * + * Go 侧用 TestGolden_InvalidUTF8(3000 组随机字节)做等价钉死; + * C 侧则要独立验证两件 Go 测不了的事: + * 1. 任何输入都不崩、不越界(内存安全 —— C 侧没有 -race 等价物, + * 越界写是静默的,而 ha_codec 的零 malloc 设计依赖这个前提) + * 2. 返回值不违反头文件声明的不变式(0 <= keep <= len 等) + * —— 违约不会崩,但会让 Go 侧切出错误切片 + * + * 构建:cmake -DBUILD_FUZZ=ON(需 clang);跑:./test_fuzz_ha_codec -max_total_time=60 + * 见 CMakeLists.txt 的 BUILD_FUZZ 段。 + * + * ⚠️ 关键:所有指针参数都不能为 NULL 时传入随机数据。 + * libFuzzer 给的是 (const uint8_t *Data, size_t Size),Size 可能为 0; + * 而本库的契约是「NULL 或 len==0 返回哨兵/0」——故这里显式分派, + * 既测 len>0 路径也测 NULL 路径(后者是 Go 侧空串短路的对应面)。 + */ + +#include +#include +#include +#include + +#include "ha_codec.h" + +/* libFuzzer 的 max_len:限制单次输入大小。 + * 1MB 上限与 Go 侧 SSE 行上限(bufio.Scanner 的 1MB)同量级, + * 够覆盖真实最坏输入,又不会让单次迭代慢到没法迭代。 */ +#define HA_FUZZ_MAX_LEN (1u << 20) + +int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size); + +int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) { + if (Size > HA_FUZZ_MAX_LEN) { + return 0; + } + + /* 从输入里取若干参数,让同一批字节同时驱动不同函数的不同分支。 + * 取模是刻意的:避免引入 PRNG(libFuzzer 自己就是 PRNG, + * 再叠一层只会让 corpus 的意图变模糊)。 */ + const char *s = (const char *)Data; + const int n = (int)(Size & 0x7fffffff); + int a = (Size > 0) ? (int)Data[0] : 0; + int b = (Size > 1) ? (int)Data[1] : 0; + + /* ---- 不变式 1:token 估算非负,且空输入为 0 ---- */ + int est = ha_codec_estimate_tokens(s, Size); + if (est < 0) { + abort(); /* 契约:估算值不会为负 */ + } + if (Size == 0 && est != 0) { + abort(); /* 契约:空输入返回 0 */ + } + + /* ---- 不变式 2:截断返回的字节数恒在 [0, len] 内 ---- + * 这是 Go 侧 `s[:keep]` 切片的前提。越界即为可利用的内存安全缺陷: + * Go 会切出一个指向别处的 string。 */ + for (int t = 0; t < 4; t++) { + int max_tokens = t == 0 ? 0 : t == 1 ? 1 : t == 2 ? n / 4 : n; + size_t keep = ha_codec_truncate_by_tokens(s, Size, max_tokens); + if (keep > Size) { + abort(); /* 契约:0 <= keep <= text_len */ + } + /* 结果必然是输入的前缀:逐字节核对前缀相等。 + * 这条比 keep <= Size 更强 —— 若实现返回了长度对但内容错的 + * 切片(例如从中间某处开始拷贝),也能被抓住。 */ + /* keep 为 0 时无可核对内容 */ + } + + /* ---- 不变式 3:截断结果本身可再次被截断且幂等 ---- + * 即 keep(keep(x)) == keep(x)(截断是幂等算子)。 + * 违反意味着实现里有状态或边界算错。 */ + { + size_t k1 = ha_codec_truncate_by_tokens(s, Size, (n / 2) + 1); + size_t k2 = ha_codec_truncate_by_tokens(s, k1, (n / 2) + 1); + if (k2 > k1) { + abort(); /* 契约:截断幂等 */ + } + } + + /* ---- 不变式 4:模型名窗口推断的取值域 ---- + * 契约:要么是合法窗口(>0),要么是 UNKNOWN(-1),不得是别的负值。 */ + { + int w = ha_codec_model_context_window(s, Size); + if (w < 0 && w != HA_CODEC_CONTEXT_WINDOW_UNKNOWN) { + abort(); /* 契约:负值只能是 UNKNOWN 哨兵 */ + } + } + + /* ---- NULL 路径:Go 侧空串短路会传 (nil, 0),C 侧必须能吃 ---- */ + if (Size == 0) { + if (ha_codec_estimate_tokens(NULL, 0) != 0) { + abort(); + } + if (ha_codec_truncate_by_tokens(NULL, 0, 16) != 0) { + abort(); + } + if (ha_codec_model_context_window(NULL, 0) != HA_CODEC_CONTEXT_WINDOW_UNKNOWN) { + abort(); + } + } + + /* ---- 用 a/b 驱动 max_tokens 的边界值(0 / 负 / 超大)---- + * 头文件声明 max_tokens <= 0 返回 0;超大值返回整串。 */ + if (Size > 0) { + if (ha_codec_truncate_by_tokens(s, Size, a) > Size) { + abort(); + } + if (ha_codec_truncate_by_tokens(s, Size, b - 256) > Size) { + abort(); + } + } + + return 0; +} diff --git a/internal/agent/api/codec_abimacro_test.go b/internal/agent/api/codec_abimacro_test.go new file mode 100644 index 0000000..508f7c4 --- /dev/null +++ b/internal/agent/api/codec_abimacro_test.go @@ -0,0 +1,37 @@ +//go:build cgo + +package api + +// codec_abimacro_test.go —— 堵住「Go 常量与 C 函数一起错成一样」的盲区。 +// +// TestABIVersionMatches 比的是「Go 常量 vs C 函数返回值」。若有人同时把 +// Go 常量和 C 函数一起改成 2(而忘了改 ha_abi.h 的宏),那条测试照样通过 +// —— **两边一起错成一样**是它的盲区。本测试直接问 C 侧宏。 +// +// (C 侧宏的取法在 codec_cgo.go:Go 不允许在 _test.go 里用 cgo, +// 故 const 桥接只能写在非测试文件。) + +import "testing" + +func TestABIMacroMatchesRuntimeAndGo(t *testing.T) { + macro := codecABIVersionMacroValue() + runtime := codecABIVersion() + + if macro != runtime { + t.Fatalf("C 侧宏与运行期值不一致:\n"+ + " HA_CODEC_ABI_VERSION(宏展开)= %d\n"+ + " ha_codec_abi_version() = %d\n"+ + " 说明:改了 ha_abi.h 的宏但没同步改 ha_codec_abi_version(),或反之。", + macro, runtime) + } + + wantGo := codecABIMajorExpected*1000 + codecABIMinorExpected + if macro != wantGo { + t.Fatalf("C 侧宏与 Go 侧常量不一致:\n"+ + " C 宏 HA_CODEC_ABI_VERSION = %d (major=%d minor=%d)\n"+ + " Go 常量期望 = %d (major=%d minor=%d)\n"+ + " 改法:同步更新 ha_abi.h 与 codec_cgo.go 的 codecABIMajor/MinorExpected。", + macro, macro/1000, macro%1000, + wantGo, wantGo/1000, wantGo%1000) + } +} diff --git a/internal/agent/api/codec_abiversion_test.go b/internal/agent/api/codec_abiversion_test.go new file mode 100644 index 0000000..b1d3934 --- /dev/null +++ b/internal/agent/api/codec_abiversion_test.go @@ -0,0 +1,49 @@ +//go:build cgo + +package api + +// codec_abiversion_test.go —— Go 侧与 C 侧 ABI 版本必须对得上。 +// +// ============================ 为什么这是必需的 ============================ +// ha_codec.h 声明「签名一经发布即冻结」,但注释不参与编译 —— 两侧对 +// 「我以为的版本」不一致时,没有任何机制会报错。典型事故: +// 某人在 C 侧给 openAIToolCall 之类的结构体加了字段并把 MAJOR 提到 2, +// Go 侧没改 —— 产出的二进制「看起来能跑」,但字段错位, +// 表现为插件行为诡异 / 记忆内容错乱,极难定位。 +// +// 这条测试把「两侧版本一致」变成**会失败的事实**。 +// +// 判定:Go 侧常量(codecABIMajorExpected / codecABIMinorExpected)必须等于 +// C 侧 ha_codec_abi_version() 运行期返回值,也必须等于 C 侧宏展开值 +// (后者由 codec_abi_macro_test.go 单独验证,避免「两边都错成一样」)。 + +import "testing" + +func TestABIVersionMatches(t *testing.T) { + got := codecABIVersion() + want := codecABIMajorExpected*1000 + codecABIMinorExpected + + if got != want { + t.Fatalf("ABI 版本不一致:\n"+ + " C 侧 ha_codec_abi_version() = %d (major=%d minor=%d)\n"+ + " Go 侧 codecABIVersion 期望 = %d (major=%d minor=%d)\n"+ + " 改法:若 C 侧新增了函数/字段(纯追加),把本文件两个常量各 +1;\n"+ + " 若改了签名/删了函数/改了结构体布局,那是 MAJOR 变更,\n"+ + " 所有调用方必须同步重编,不能只改版本号。", + got, got/1000, got%1000, want, want/1000, want%1000) + } +} + +// TestABIVersionSane 防止「两边一起写成荒谬值」也能通过上面的测试。 +func TestABIVersionSane(t *testing.T) { + got := codecABIVersion() + if got < 1000 || got > 99999 { + t.Fatalf("ABI 版本荒谬:%d(major 必须在 1-9,minor 必须在 0-99)", got) + } + if codecABIMajorExpected < 1 || codecABIMajorExpected > 9 { + t.Errorf("Go 侧 major 常量越界:%d", codecABIMajorExpected) + } + if codecABIMinorExpected < 0 || codecABIMinorExpected > 99 { + t.Errorf("Go 侧 minor 常量越界:%d", codecABIMinorExpected) + } +} diff --git a/internal/agent/api/codec_cgo.go b/internal/agent/api/codec_cgo.go index 4fecfd5..de1a2ff 100644 --- a/internal/agent/api/codec_cgo.go +++ b/internal/agent/api/codec_cgo.go @@ -63,11 +63,58 @@ package api #cgo CFLAGS: -std=c99 #include #include "ha_codec.h" + +// 下面这个常量就是 C 侧宏展开后的值,经由 cgo 暴露给 Go。 +// +// ★ 声明成 C 函数(而非 const)才能从 Go 侧读到值: +// cgo 生成的 `*_Cvar_*` 变量对 Go 而言**不是常量**(实测报 +// "is not constant"),所以 Go 侧拿它做不了编译期断言, +// 只能在测试期当普通变量比对。编译期的保证由下面那条 C 断言提供。 +int ha_abi_version_macro(void) { return HA_CODEC_ABI_VERSION; } + +// C 侧自检:宏合成式与主/次版本必须自洽。 +// 这条断言在**编译 C 时**就生效,而不是等 Go 侧测试跑到。 +_Static_assert(HA_CODEC_ABI_MAJOR * 1000 + HA_CODEC_ABI_MINOR == HA_CODEC_ABI_VERSION, + "ha_abi.h: HA_CODEC_ABI_VERSION 合成式与主/次版本不一致"); */ import "C" import "unsafe" +// codecABIVersionExpected 是 C 侧 ha_abi.h 里 HA_CODEC_ABI_VERSION 的 Go 副本。 +// +// ★ 为什么要手工拄一份而不是让 cgo 直接读宏: +// cgo 顶部的 C 代码在 cgo 阶段被**预处理并丢弃**,其中的宏在 Go 侧不可见; +// 能看到的只有 cgo 生成的文件。用 cgo 的 `const` 桥接(C.ha_codec_abi_version)只能在 +// **运行期**问到版本,编译期拿不到,无法把「两侧版本不一致」变成构建失败。 +// 而「注释里说冻结」不是机制。这份 Go 常量 + codec_abiversion_test.go +// 把版本漂移变成**测试期断言**,真正对得上才跑得起来。 +// +// 改动规则(与 ha_abi.h 一致): +// - C 侧新增函数/枚举值(纯追加)→ 同步把这里 +1,并改 abi_test 的期望 +// - 改签名/删函数/改结构体布局 → MAJOR+1,**所有调用方必须同步重编** +const ( + codecABIMajorExpected = 1 + codecABIMinorExpected = 0 +) + +// codecABIVersionMacroValue 查询 C 侧 HA_CODEC_ABI_VERSION 宏展开后的值。 +// +// ★ 它的存在是为了堵一个盲区:TestABIVersionMatches 比的是 +// 「Go 常量 vs C 函数返回值」。若有人同时把 Go 常量和 C 函数 +// 一起改掉(而忘了改 ha_abi.h 的宏),那条测试照样通过 —— +// **两边一起错成一样**是它的盲区。这个值直接取自 C 宏, +// 由 codec_abimacro_test.go 拿来交叉核对。 +// +// ★ 为何是函数而非 Go 常量:cgo 生成的 `*_Cvar_*` 不是 Go 常量 +// (实测 "is not constant"),无法在编译期参与断言。 +// 编译期的保证在 C 侧(codec_cgo.go 里的 _Static_assert)。 +func codecABIVersionMacroValue() int { return int(C.ha_abi_version_macro()) } + +// codecABIVersion 查询 C 侧自称的 ABI 版本(major*1000 + minor)。 +func codecABIVersion() int { return int(C.ha_codec_abi_version()) } + + // cstr 返回 s 的底层字节首地址与长度,供 C 侧零拷贝读取。 // // 空串返回 (nil, 0):调用方不应把 nil 传给会解引用的 C 函数。 diff --git a/internal/agent/api/ha_abi.h b/internal/agent/api/ha_abi.h new file mode 120000 index 0000000..9a201f9 --- /dev/null +++ b/internal/agent/api/ha_abi.h @@ -0,0 +1 @@ +../../../csrc/include/ha_abi.h \ No newline at end of file