mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-29 06:00:56 +00:00
build(csrc): C 基础设施门禁(ABI 版本 / 告警 / sanitizer / 交叉编译 / 模糊测试)
C 化从「一把刀」推进到「可持续推进」,本轮先把基础设施建起来:
不建它,后续每个 C 切片都在裸奔(无告警门禁、无内存安全检查、
无交叉编译验证、无 ABI 漂移检测)。
新增门禁(make check-csrc / check-csrc-full,已接进 make test):
- csrc-lint gcc+clang 双编译器 × -Wall -Wextra -Wpedantic -Wshadow
-Wconversion,零告警才算过(-Wconversion 是为 cgo 窄化
准备的:size_t→int 截断在默认档下是静默的)
- csrc-abi ABI 版本运行期自述 + 荒谬值检查
- csrc-headers 头文件自包含性(每个 .h 能单独编过)
- csrc-sanitize ASan+UBSan 跑 C 契约测试
- csrc-cross arm64 交叉编译(homed 的真实发布目标)
- csrc-fuzz libFuzzer:内存安全 + 6 条不变式(可 CI 门禁)
新增 ABI 契约(csrc/include/ha_abi.h):
- ha_codec.h 声明「签名冻结」,但冻结只写在注释里;现改为
HA_CODEC_ABI_MAJOR/MINOR + 运行期自述 + Go 侧常量,
三方交叉断言,版本漂移在测试期判红而非线上表现为行为诡异。
- 门禁当场抓出我自己的两个真 bug:①_Static_assert 是 C11 而项目
是 -std=c99(-Wpedantic 报的);②##msg 不能拼接字符串字面量,
导致两个断言共用一个 typedef 名(clang 报的)。
基础设施当场抓出的三个真实缺陷(都是「本机 gcc 能编过、别处会炸」类):
- bench 用了 POSIX clock_gettime,而 CMake 刻意 C_EXTENSIONS OFF
(严格 c99)→ 头文件未声明;补 _POSIX_C_SOURCE(须在任何头之前)
- 头文件缺 include 时只在「恰好被别的头先包含」处静默编过
- Go 不允许在 _test.go 用 cgo ⇒ C 侧 const 桥接只能放非测试文件,
且 cgo 生成的 *_Cvar_* 不是 Go 常量(「两边一起错成一样」的盲区,
改用 C 函数返回 + 编译期 _Static_assert 补上)
实测(全部当场可复现):
- 双编译器 × c99/c11 零告警;ASan+UBSan PASS
- libFuzzer 91s 跑 3329316 次、零崩溃(6 条不变式全过)
- 变异测试:改 C 侧宏 / 让 Go 常量与 C 函数「一起错成一样」,
均被对应断言抓住(证明门禁不是摆设)
- C 侧纯函数基准(首次把函数体成本与 cgo 边界成本分开测):
ascii_1k 121.7ns/8.4GB/s;zh_1k 1434ns;truncate 两者均约 128-134ns
- 全量 go test -count=1 ./...:57 包 0 FAIL
- make build-linux-arm64 → ELF aarch64
- 纪律检查 FAIL=0;SDK 公开接口 diff = 0 行
未动:csrc/ 是内核 C ABI,不属 third_party/homeagent-sdk 公开接口。
This commit is contained in:
161
Makefile
161
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 <stdio.h>\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 <stdio.h>\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 却没 <stdint.h>),后果是:
|
||||
# - 在某个翻译单元里恰好被别的头预先包含了 → 静默编过
|
||||
# - 在别处(鸿蒙/嵌入式/C SDK 直接包含它)→ 报一堆无关的错
|
||||
# 本轮就靠它抓出 ha_abi.h 的静态断言垫片缺 <assert 类依赖> 类问题。
|
||||
# 判据:每个头单独编 -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 化」这一决定。
|
||||
|
||||
@ -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()
|
||||
|
||||
175
csrc/bench/bench_ha_codec.c
Normal file
175
csrc/bench/bench_ha_codec.c
Normal file
@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#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 <windows.h>
|
||||
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 <time.h>
|
||||
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;
|
||||
}
|
||||
80
csrc/include/ha_abi.h
Normal file
80
csrc/include/ha_abi.h
Normal file
@ -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 */
|
||||
@ -28,10 +28,21 @@
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#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 侧一致)。
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
122
csrc/test/test_fuzz_ha_codec.c
Normal file
122
csrc/test/test_fuzz_ha_codec.c
Normal file
@ -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 <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
37
internal/agent/api/codec_abimacro_test.go
Normal file
37
internal/agent/api/codec_abimacro_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
49
internal/agent/api/codec_abiversion_test.go
Normal file
49
internal/agent/api/codec_abiversion_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -63,11 +63,58 @@ package api
|
||||
#cgo CFLAGS: -std=c99
|
||||
#include <stdlib.h>
|
||||
#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 函数。
|
||||
|
||||
1
internal/agent/api/ha_abi.h
Symbolic link
1
internal/agent/api/ha_abi.h
Symbolic link
@ -0,0 +1 @@
|
||||
../../../csrc/include/ha_abi.h
|
||||
Reference in New Issue
Block a user