Files
HomeAgent/internal/agent/api/codec_jsongolden_test.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

270 lines
9.0 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_jsongolden_test.go —— ha_json_scan(C)与 encoding/json(Go)逐值对照。
//
// ============================ 这是本刀最重要的验收 ============================
// 理由:C 侧手写扫描器最容易出的错不是崩溃,而是**静默的分叉** ——
// 某个输入 Go 接受而 C 拒绝(或反之)、某个转义解码结果差一个字节。
// 而这类分叉在生产里的表现是「内容偶尔少一个字符」「某些块被静默丢弃」,
// 极难归因。因此必须有**同一批输入、两个实现、逐值比对**的测试。
//
// 参照第一刀的做法(codec_golden_test.go),此处比的是
// C: ha_json_scan 的 scan / decode / get_int
// Go: encoding/json 的等价行为
//
// 覆盖:语法严格性、键大小写不敏感、重复键后者胜、\u 与代理对、
// 非法 UTF-8 → U+FFFD、整数溢出/小数/指数、畸形成员的辨别。
import (
"encoding/json"
"math/rand"
"strconv"
"strings"
"testing"
)
// -----------------------------------------------------------------
// 1. 语法严格性:C 的 skip 与 Go 的 json.Valid 必须一致
// -----------------------------------------------------------------
func TestJSONGolden_SyntaxVsValid(t *testing.T) {
cases := []string{
`{}`, `{"a":1}`, `{"a":null}`, `{"a":true}`, `{"a":-1}`,
`{"a":1.5}`, `{"a":1e2}`, `{"a":[]}`, `{"a":{}}`,
`{"a":"b"}`, `{"a":"A"}`, ` {"a" : 1 } `,
`{"a":"\u4f60\u597d"}`, `{"a":"\ud83d\ude00"}`,
`{"a":{"b":[1,2,{"c":3}]}}`, `{"a":1,"b":2}`,
`{"a":1,"a":2}`, // 重复键(合法)
// 以下应与 json.Valid 一致地失败
`{`, `}`, ``, `{"a"}`, `{"a":}`, `{"a":1,}`, `{'a':1}`,
`{"a":01}`, `{"a":1.}`, `{"a":.5}`, `{"a":1e}`, `{"a":-}`,
`{"a":tru}`, `{"a":1 "b":2}`, `{"a":"unclosed`,
`{"a":"bad\ncontrol"}`, `{"a":"\q"}`, `{"a":"\u00"}`,
`[1,2,]`, `{"a":[1,]}`, `{"a":1}{"b":2}`,
`{"a":+1}`, `{"a":Infinity}`, `{"a":NaN}`,
}
for _, in := range cases {
cOK := cjsSkipStrict(in)
goOK := json.Valid([]byte(in))
if cOK != goOK {
t.Errorf("语法分歧 %q: C.skip=%v, json.Valid=%v", in, cOK, goOK)
}
}
}
// -----------------------------------------------------------------
// 2. 成员迭代:C 与 Go 必须数到同样的键、且 complete 判定一致
// -----------------------------------------------------------------
// goObjectKeysStrict 用 Go 自己的遍历统计键数;任何 unmarshal 失败即视为 0。
func goKeys(in string) (int, bool) {
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(in), &m); err != nil {
return 0, false
}
return len(m), true
}
func TestJSONGolden_MembersCount(t *testing.T) {
cases := []string{
`{}`, `{"a":1}`, `{"a":1,"b":2}`, `{"a":1,"b":2,"c":3}`,
`{"a":{"x":1},"b":[1,2]}`, `{"A":1,"a":2}`, // 大小写不同的键都算
`{"":1}`, `{"a":"}"}`, `{"a":"{"}`, `{"a":"x,y,z"}`,
`{"a":{"n":1},"b":{"n":2}}`,
`{"a":1,}`, `{"a":1`, `{"a"}`, `{"a":}`,
}
for _, in := range cases {
cInit, cCount, cComplete := cjsWalkMembers(in)
goCount, goOK := goKeys(in)
// init 的语义只是「首字符是 '{'」——它**不可能**知道对象是否闭合,
// 所以不能用 Go 的 unmarshal ok 来判它(那是 complete 的职责)。
// 这里分开断言:
// init ↔ 首字符是 '{'
// complete ↔ Go unmarshal 成功(整体良构)
wantInit := strings.HasPrefix(strings.TrimSpace(in), "{")
if cInit != wantInit {
t.Errorf("init 分歧 %q: C.init=%v, 期望 %v", in, cInit, wantInit)
continue
}
if !cInit {
continue
}
if cComplete != goOK {
t.Errorf("complete 分歧 %q: C=%v, Go=%v", in, cComplete, goOK)
continue
}
if goOK && cCount != goCount {
t.Errorf("成员数分歧 %q: C=%d, Go=%d", in, cCount, goCount)
}
}
}
// -----------------------------------------------------------------
// 3. 字符串解码:C 与 Go 的 unquote 必须逐字节一致
// -----------------------------------------------------------------
func TestJSONGolden_StringDecode(t *testing.T) {
rawCases := []string{
``, `a`, `hello world`, `中文`, `你好😀`,
`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`,
`\u0041`, `\u00e9`, `\u4f60\u597d`, `\ud83d\ude00`, `\u0000`,
`mixed \u4e2d\u6587 and ascii`,
`\ud83d` + `real`, // 孤立高代理
`\udc00` + `real`, // 孤立低代理
`\ud83dx`, // 高代理 + 非转义
`\ud83d\u0041`, // 高代理 + 非低代理
"\xff", "\xfe", "\xff\xfe", "\xc3", "\xc3\x28", "\xe0\x80\x80",
"\xed\xa0\x80", "\xf5\x80\x80\x80", "\xf0\x9f\x98\x80", // 正常 4 字节
"a\xffb", "\x80", "\xbf",
`\uD83D\uDE00`, // 大写十六进制代理对
}
for _, raw := range rawCases {
// Go 侧参照:把 raw 当作 JSON 字符串体的内容,解码
goOut, goErr := goUnquoteBody(raw)
doc := `"` + raw + `"`
// C 侧:先取字符串 span(去掉引号),再解码
cRaw, rawOK := cjsScanString(doc)
if !rawOK {
if goErr == nil {
t.Errorf("C 拒绝但 Go 接受: raw=%q", raw)
}
continue
}
cOut, cOK := cjsDecode(cRaw)
if goErr != nil {
if cOK {
t.Errorf("C 接受但 Go 报错: raw=%q -> %q", raw, cOut)
}
continue
}
if !cOK {
t.Errorf("C 解码失败但 Go 成功: raw=%q 期望 %q", raw, goOut)
continue
}
if cOut != goOut {
t.Errorf("解码分歧 raw=%q:\n C = %q (% x)\n Go = %q (% x)",
raw, cOut, cOut, goOut, goOut)
}
}
}
// -----------------------------------------------------------------
// 4. 整数:C 与 Go(strconv.ParseInt 语义)一致
// -----------------------------------------------------------------
func TestJSONGolden_GetInt(t *testing.T) {
cases := []string{
"0", "1", "-1", "12345", "-99999", "2147483647", "-2147483648",
"9223372036854775807", "-9223372036854775808",
"9223372036854775808", "-9223372036854775809",
"99999999999999999999", "1.5", "1e2", "", "abc", "0x10", "+1", "007",
"0", "-0", "00", "0.0", " 1", "1 ",
}
for _, in := range cases {
cGot, cOK := cjsGetInt(in)
var cVal int64 = cGot
// Go 参照:按 **JSON 整数语法**(而非 strconv 的宽松十进制)判定。
// 差别在 "007"/"+1":strconv.ParseInt 接受,但 JSON 语法禁止前导零与前导 +。
// 本库的契约是「这是不是 JSON 整数」(以便调用方按
// 「类型不匹配 ⇒ 整块作废」处理),故参照必须用同一判据。
goOK := false
var goVal int64
if isJSONIntSyntax(in) {
v, err := strconv.ParseInt(in, 10, 64)
if err == nil {
goOK, goVal = true, v
}
// 溢出(ErrRange)⇒ 与 C 一致:判为「不是可用整数」
}
if cOK != goOK {
t.Errorf("整数可用性分歧 %q: C=%v, Go=%v", in, cOK, goOK)
continue
}
if cOK && cVal != goVal {
t.Errorf("整数值分歧 %q: C=%d, Go=%d", in, int64(cVal), goVal)
}
}
}
func isJSONIntSyntax(s string) bool {
i := 0
if i < len(s) && s[i] == '-' {
i++
}
if i >= len(s) {
return false
}
if s[i] == '0' {
return i+1 == len(s)
}
if s[i] < '1' || s[i] > '9' {
return false
}
for ; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// -----------------------------------------------------------------
// 5. 随机字节:两侧的「是否接受」必须一致(畸形输入等价性)
// -----------------------------------------------------------------
func TestJSONGolden_RandomBytes(t *testing.T) {
rng := rand.New(rand.NewSource(20260926))
alphabet := []byte(`{}[]",:0123456789tfnul \` + "\n\t\xff\x80")
mismatch := 0
for iter := 0; iter < 20000 && mismatch < 5; iter++ {
n := rng.Intn(40)
b := make([]byte, n)
for i := range b {
b[i] = alphabet[rng.Intn(len(alphabet))]
}
cOK := cjsSkipStrict(string(b))
goOK := json.Valid(b)
if cOK != goOK {
mismatch++
t.Errorf("随机输入分歧 %q: C.skip=%v json.Valid=%v", b, cOK, goOK)
}
}
}
// -----------------------------------------------------------------
// 6. ABI
// -----------------------------------------------------------------
func TestJSONScanABIVersion(t *testing.T) {
if got := cjsABIVersion(); got != 1000 {
t.Errorf("ha_json_scan ABI = %d, 期望 1000 (1.0)", got)
}
}
// -----------------------------------------------------------------
// 辅助
// -----------------------------------------------------------------
// goUnquoteBody 用 encoding/json 自身解码一个 JSON 字符串体(raw = 不含两端引号)。
//
// ★ 正确做法是**直接把 body 原样**放进引号里交给 Unmarshal ——
// body 里本来就带着它自己的转义(`\n` 是两个字节),若在此处再转义一遍,
// 就把「转义序列」变成了「字面量」,参照值会整体跑偏。
// 实测踩过:初版对 body 里的 `\` 和 `"` 做了二次转义,
// 导致 Go 侧期望 `\n`(两字节)而 C 侧正确给出换行符 ——
// 测试报了一堆「分歧」,其实错的是测试自己的参照。
func goUnquoteBody(body string) (string, error) {
var out string
if err := json.Unmarshal([]byte(`"`+body+`"`), &out); err != nil {
return "", err
}
return out, nil
}