Files
HomeAgent/internal/agent/api/codec_jsonscan_cgo.go
JianFeeeee 4d3962a845 feat(csrc): 第二刀 —— 零分配 JSON 扫描/取值层 ha_json_scan(含黄金对照)
C 化第二刀:为协议编解码层铺 JSON 底座。**本刀只交付库 + 验收,
未改 Go 生产路径**(接线是独立一步,库先验完再换产线)。

为什么是它:SSE 单块解析(parseOpenAICompatibleStreamChunkFull)是每个流式
chunk 都要跑的最热路径,实测 1937ns/13allocs(content 块)、3122ns/21allocs
(toolcall 块),而纯字节扫描理论下限 133ns/1alloc —— 差距 15~23×。
一次 1 万块的会话 = 1~2 万次堆分配,正是 GC 抖动的来源。

为什么不复用 SDK 的 remotedevice/ha_json.c(实测三缺陷,不可直接复用):
  ① 无 \u 解码:\u4f60\u597d → ?0?d?d?0(非 ASCII 全靠转义时内容直接损坏)
  ② 只有 _get_int 无浮点:temperature:0.7 静默变 0
  ③ null 与「键缺失」不可区分
  外加它是 DOM + malloc,与本层「不 malloc / 零拷贝 / 纯函数」正交。

设计:scan(结构,零分配零解码)+ extract(取值,按需解码)两段分离。
content 可能是很大的多模态数组,而 stringifyContent 只需要 text 字段拼起来;
若 scan 就解码并分配缓冲,等于把成本付给不需要它的调用方。

★ 被测试抓出 7 个真实缺陷(写 C 时同一逻辑我读三遍都认为正确):
  1 代理对合成成功后未跳过 unconditionally 的 U+FFFD 发射(😀 → 两个 FFFD)
  2 过长编码检查用了只含首字节位的 cp(「你」→ 6 个 FFFD)
  3 members_next 只报值起点不消费值 → 游标停在值前(模糊测试第一轮抓到)
  4 扫描阶段不校验转义字符合法性({"a":"\q"} C 判合法、json.Valid=false)
  5 扫描阶段不校验 \u 后四位十六进制(同上)
  6 get_int 接受前导零(007 / 00)
  7 cgo 桥接把 C 结构体声明为 Go 局部变量 → 运行时 panic
     (cgo argument has Go pointer to unpinned Go pointer)
  其中 4 个是「静默分叉」——不崩、不报错,生产里表现为「内容少一个字符」
  或「某些块被静默丢弃」,极难归因。这正是黄金对照不可省的理由。

★ 另纠正我自己两次错误的「真值」(比代码 bug 更危险,会变成错误规格):
  第一版真值表里 content:{} 的花括号少了一层,把「我写错 JSON」误读成
  「Go 对 content 严格」。修正后实测发现一对方向相反的语义:
  content 走 interface{} 宽松({}→"{}"、true→"true"),
  reasoning_content/usage/finish_reason 强类型严格(123 ⇒ 整块作废)。
  照错误表写 C 会产出「比 Go 更严格」的实现,静默丢弃本该生效的块。

两个由缺陷倒逼的设计决定:
  - members_next 返回**完整值 span** 并内部跳过 ⇒ 「返回 1」蕴含「成员良构」。
    要求调用方自己推进游标的 API 是错的:忘一次就解析到上一个值且不报错。
  - members_complete() 区分「正常扫到 }」与「输入畸形」,否则无法复刻 Go 严格性。

同时修两个基础设施目标对「多源文件/多测试」的适配:
  - csrc-sanitize:每个契约测试各自链接(多个 main 合链会 multiple definition,
    而报错被吞后会被误报成「本机无 sanitizer」——一个假的 SKIP)
  - csrc-cross:多源文件改用 -fsyntax-only 逐文件(gcc 不支持多源单 -o)

实测(全部当场可复现):
  - C 契约测试 119 项断言全过;黄金对照 5 组全过(语法/成员/解码/整数/随机字节)
  - libFuzzer 4948 万次运行零崩溃(121s)
  - ASan+UBSan PASS(两个契约测试各跑);gcc+clang 零告警;arm64 交叉编译 0 告警
  - 全量 go test -count=1 ./... 0 FAIL;make build-linux-arm64 → ELF aarch64
  - 纪律检查 SDK 公开接口 diff = 0 行(未触碰 SDK)

决策关闭(jianf 本轮裁决):C 实现留主仓 csrc/(它本就是替换内核 Go 实现,
SDK 从未被触碰,跨端复用才需进 SDK 而它们不调用本层);ha_json.c 不复用;
下一刀即协议编解码层。
2026-09-26 10:07:27 +08:00

211 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build cgo
package api
// codec_jsonscan_cgo.go — ha_json_scan(C)的 cgo 桥接。
//
// ============================ 为什么桥接在非测试文件里 ============================
// Go **不允许在 _test.go 里用 cgo**(实测:use of cgo in test ... not supported)。
// 而 C 侧静态链接函数没有对应的 Go 声明就没法调用 ⇒ 桥接必须落在这里,
// 由 codec_jsongolden_test.go(纯 Go 测试)来验证其语义。
//
// 与 codec_cgo.go 同理:本包是 cgo-only(编解码层已完全 C 化),
// 所以这些桥接函数在 CGO_ENABLED=0 下不存在,而那正是**有意的响亮失败**。
/*
#cgo CFLAGS: -std=c99
#include <stdlib.h>
#include "ha_json_scan.h"
// cgo 编不了 C 宏,这里用一个小 helper 把 C 侧结果取出来。
// span 指向 Go 传进来的原缓冲(零拷贝),Go 侧用 unsafe 读回。
static ha_span go_scan_members(ha_json_members *m, ha_span *key) {
ha_span val;
if (!ha_json_members_next(m, key, &val)) {
ha_span none;
none.p = NULL;
none.len = 0;
return none;
}
return val;
}
static int go_members_complete(const ha_json_members *m) {
return ha_json_members_complete(m);
}
// 严格判定:整串**恰好**是一个 JSON 值(尾部只允许空白)。
//
// ★ 全部逻辑留在 C 侧,故意不让 Go 把 ha_json_scan 结构体传进来:
// cgo 规则禁止「Go 指针指向的 Go 指针」。把 C 结构体声明成 Go 变量
// 递给 C 时,若该变量因逃逸分析被堆分配,运行时无法证明它不含
// Go 指针 ⇒ 直接 panic
// (实测报 cgo argument has Go pointer to unpinned Go pointer)。
// 正确做法是「只传裸指针 + 长度给 C,让 C 自己持有游标」——
// 这也与库本身「零分配、调用方栈上持有」的设计一致。
static int go_skip_strict(const char *s, size_t n) {
ha_json_scan sc;
ha_json_scan_init(&sc, s, n);
if (!ha_json_skip(&sc)) {
return 0;
}
(void)ha_json_scan_ws(&sc);
return ha_json_scan_eof(&sc);
}
static int go_skip(const char *s, size_t n) {
ha_json_scan sc;
ha_json_scan_init(&sc, s, n);
return ha_json_skip(&sc);
}
static int go_scan_string(const char *s, size_t n, size_t *out_len) {
ha_json_scan sc;
ha_json_scan_init(&sc, s, n);
ha_span raw;
if (!ha_json_scan_string(&sc, &raw)) {
return 0;
}
*out_len = raw.len;
return 1;
}
static int go_decode(const char *p, size_t n, char *out, size_t cap, size_t *outlen) {
ha_span raw;
raw.p = p;
raw.len = n;
size_t k = ha_json_decode_string_into(raw, out, cap);
if (k == (size_t)-1) {
return 0;
}
*outlen = k;
return 1;
}
static int go_get_int(const char *p, size_t n, long long *out) {
ha_span raw;
raw.p = p;
raw.len = n;
return ha_json_get_int(raw, out);
}
static int go_abi(void) { return ha_json_scan_abi_version(); }
*/
import "C"
import "unsafe"
// 供测试调用的 C 侧薄封装(C 的类型无法直接出现在测试文件里)
func cjsSkip(s string) bool {
p, n := cstr2(s)
return C.go_skip(p, n) == 1
}
func cjsMembersInit(m *C.ha_json_members, s string) bool {
p, n := cstr2(s)
return C.ha_json_members_init(m, p, n) == 1
}
// cjsMembersStep 推进一次迭代,只把**键**交回 Go。
//
// ★ 为什么只返回键:cgo 规则禁止把「Go 指针指向的 Go 指针」传给 C
// (cgo argument has Go pointer to unpinned Go pointer)——若把 key 与
// value 两个 span 都交回 Go,再在同一个调用里传回 C,就会构成
// 「Go 切片 → Go 指针 → Go 指针」的未固定链,运行时直接 panic。
// 所以每次跨语言只搬运**一个**字符串,其余信息留到下一次调用。
//
// ★ 值 span 只在需要时**在 C 侧**用(见 cjsWalkMembers)。
func cjsMembersStep(m *C.ha_json_members) (key string, ok bool) {
var ck C.ha_span
v := C.go_scan_members(m, &ck)
if v.p == nil {
return "", false
}
return unsafeString(ck.p, int(ck.len)), true
}
func cjsMembersComplete(m *C.ha_json_members) bool {
return C.go_members_complete(m) == 1
}
func cjsScanString(s string) (string, bool) {
p, n := cstr2(s)
var outLen C.size_t
if C.go_scan_string(p, n, &outLen) == 0 {
return "", false
}
// 去掉两端引号
if n < 2 {
return "", false
}
return string(s[1 : int(n)-1]), true
}
func cjsDecode(raw string) (string, bool) {
// 上界:每字节最坏变一个 3 字节 U+FFFD
buf := make([]byte, len(raw)*3+16)
var outLen C.size_t
p := cstrp(raw)
ok := C.go_decode(p, C.size_t(len(raw)), cstrb(buf), C.size_t(len(buf)), &outLen) == 1
if !ok {
return "", false
}
return string(buf[:int(outLen)]), true
}
func cjsGetInt(s string) (int64, bool) {
p, n := cstr2(s)
var v C.longlong
if C.go_get_int(p, n, &v) != 1 {
return 0, false
}
return int64(v), true
}
func cjsABIVersion() int { return int(C.go_abi()) }
// unsafeString 把 C 返回的 span(指向 Go 原缓冲)读成 Go string。
func unsafeString(p *C.char, n int) string {
if p == nil || n < 0 {
return ""
}
bytes := (*[1 << 30]byte)(unsafe.Pointer(p))[:n:n]
return string(bytes)
}
// cjsWalkMembers 遍历一个对象字符串,返回 (init 成功, 成员数, 是否正常结束)。
//
// 存在的原因:Go 测试文件**不能引用 C 类型**(没有 cgo),
// 而 ha_json_members 必须在 Go 栈上持有(零分配,见头文件设计约束)。
// 故由本文件在内部持有并把结果压成三个 Go 值。
func cjsWalkMembers(s string) (inited bool, count int, complete bool) {
var m C.ha_json_members
if !cjsMembersInit(&m, s) {
return false, 0, false
}
for {
_, ok := cjsMembersStep(&m)
if !ok {
break
}
count++
if count > 100000 {
break // 死循环保护
}
}
return true, count, cjsMembersComplete(&m)
}
// cjsSkipStrict 复刻 Go json.Unmarshal 的严格性:整个输入必须是**恰好一个**
// JSON 值,尾部除空白外不得有残留。
//
// ★ 为什么测试不能只调 ha_json_skip:skip 的语义是「跳过这里的一个值」,
// 它成功返回并不能证明「整串就是这一个值」。实测 `{"a":1}{"b":2}`
// 在 skip 下成功,而 json.Valid=false —— 这正是两者职责的差别。
// 内核协议层要的是严格语义,故这里显式做尾部校验。
func cjsSkipStrict(s string) bool {
p, n := cstr2(s)
return C.go_skip_strict(p, n) == 1
}