Files
HomeAgent/csrc/include/ha_json_scan.h
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

233 lines
11 KiB
C
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.

#ifndef HA_JSON_SCAN_H
#define HA_JSON_SCAN_H
/*
* ha_json_scan — HomeAgent 内核 LLM 协议层的 JSON 扫描/取值层(C 实现)
*
* ============================ 定位 ============================
* 本库**不是**通用 JSON 库,是**流式协议分块解析**专用的零分配扫描层。
* 它服务 `parseOpenAICompatibleStreamChunkFull`(每个 SSE chunk 跑一次的最热路径)。
*
* ★ 为什么不复用 SDK 的 remotedevice/src/ha_json.c(实测,见 plan.md §七):
* 1. 无 `\u` 解码 —— `\u4f60\u597d` 得到 `?0?d?d?0`(LLM 内容全靠转义时直接损坏)
* 2. 只有 `_get_int`,无浮点 —— `temperature:0.7` **静默**变 0
* 3. `null` 与「键缺失」不可区分
* 4. 架构是 **DOM + malloc**,与「不 malloc / 零拷贝 / 纯函数」正交
* 它的定位是 remotedevice 设备通道,不是 LLM 协议层。
*
* ============================ 设计:scan / extract 两段分离 ============================
* **scan** 只出结构 span(键 span / 值 span),零分配、零解码、零求值。
* **extract** 按 span 取值,解码只发生在真正需要它的调用方身上。
*
* 为什么必须分离:`content` 可能是很大的多模态数组,而 `stringifyContent`
* 只需要「把 text 字段拼起来」。若 scan 阶段就为每个字符串 `\u` 解码并分配
* 缓冲,等于把解码成本付给了不需要它的调用方 —— 那正是我们要消灭的分配。
*
* ============================ 不可协商的约束(与 ha_codec.h 同标准) ============================
* 1. 只吃 `const char*` + **显式长度**,不要求 NUL 结尾
* (否则又是 `strlen` + 拷贝的老问题,见第一刀 §7.1 的 82% 自找开销)
* 2. **不 malloc**:结果一律以 span(指针+长度)回给调用方,Go 侧零拷贝切片
* 3. 无状态、纯函数、线程安全(不写全局可变状态)
* 4. 语法语义必须与 Go `encoding/json` **一致**,由黄金对照测试钉死
*
* ============================ 语义对齐(易踩,全部实测) ============================
* - 键匹配**大小写不敏感**(Go `encoding/json` 行为)
* - 字符串取值时非法 UTF-8 每字节替换为 U+FFFD(与 Go 一致)
* - 重复键**后者胜**
* - 本层**不做类型检查**:`{"a":{}}` 对 `a` 的扫描成功,是否「类型不对应报错」
* 由调用方按 Go 的 interface{} / 强类型语义决定(见 §三.2.2 的实测)
*/
#include <stddef.h>
#include "ha_abi.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ==================== ABI 版本(与 ha_abi.h 同步) ==================== */
#define HA_JSON_SCAN_ABI_MAJOR 1
#define HA_JSON_SCAN_ABI_MINOR 0
#define HA_JSON_SCAN_ABI_VERSION \
(HA_JSON_SCAN_ABI_MAJOR * 1000 + HA_JSON_SCAN_ABI_MINOR)
HA_STATIC_ASSERT(HA_JSON_SCAN_ABI_MAJOR >= 1 && HA_JSON_SCAN_ABI_MAJOR <= 9,
ha_jsonscan_abi_major_in_range);
HA_STATIC_ASSERT(HA_JSON_SCAN_ABI_MINOR >= 0 && HA_JSON_SCAN_ABI_MINOR <= 99,
ha_jsonscan_abi_minor_in_range);
/* 返回 HA_JSON_SCAN_ABI_VERSION(供 Go 侧与日志核对)。 */
int ha_json_scan_abi_version(void);
/* ==================== span 与扫描器 ==================== */
/* 字节区间 [p, p+len)。指针指向**调用方的原缓冲**,本库从不持有或释放。 */
typedef struct {
const char *p;
size_t len;
} ha_span;
/* 扫描器:对一段 JSON 文本的只读游标。
*
* ★ 就地结构体(非指针):调用方在栈上持有,零分配。
* 但因此**不可拷贝后混用**(拷贝出的副本与原游标各自独立推进)。
*/
typedef struct {
const char *s; /* 缓冲区起点 */
size_t n; /* 缓冲区长度 */
size_t i; /* 当前游标偏移 */
} ha_json_scan;
/* 用 (s, n) 初始化扫描器,游标置于起点。s 可为 NULL(此时按 n=0 处理)。 */
void ha_json_scan_init(ha_json_scan *sc, const char *s, size_t n);
/* 跳过前导 ASCII 空白(空格 / \t / \n / \r)。返回是否已到结尾。 */
int ha_json_scan_ws(ha_json_scan *sc);
/* 当前是否已到结尾(不含空白跳过)。 */
int ha_json_scan_eof(const ha_json_scan *sc);
/* 跳过**一个完整的 JSON 值**(对象 / 数组 / 字符串 / 数字 / 字面量)。
*
* 用于二次进数组内部(如 stringifyContent 取数组元素的 text 字段):
* 先 skip 前面的元素,再对目标元素单独扫描。
* 返回 0 表示语法错误,1 表示成功。成功后游标停在该值之后。
*/
int ha_json_skip(ha_json_scan *sc);
/* 解析一个字符串值,出**原始字节 span**(含转义序列,未解码)。
*
* 入参:游标应停在 `"` 上(或之前的空白,函数自己跳过空白)。
* 出参 raw:不含两端引号的原始内容 span(指向原缓冲,零拷贝)。
* 返回 0 = 语法错误(未闭合 / 非字符串)。
*
* ★ 注意:不做 `\u` 解码、不做非法 UTF-8 替换 —— 那是 extract 阶段的事。
*/
int ha_json_scan_string(ha_json_scan *sc, ha_span *raw);
/* ==================== 顶层对象:扫描出键值对 ==================== */
/*
* 顶层对象的迭代器。
*
* ★ 为什么由本库来切「顶层逗号」而不是让 C 侧只解析第一个键:
* LLM 的 `content` 里常含 `{`、`}`、`,`(代码、JSON 片段、模板)。
* 若调用方自己按逗号切开顶层,会被内容里的逗号错切。
* 本库扫**字符串感知**的边界,保证只在真正的顶层分隔符处切分。
*/
typedef struct {
ha_json_scan sc; /* 游标 */
int started; /* 是否已消费过至少一个成员 */
int done; /* 迭代是否已结束(正常或异常) */
int error; /* 结束原因:1 = 输入畸形(而非正常的 '}') */
} ha_json_members;
/* 初始化顶层对象迭代。非法(首个非空白字符不是 '{')时返回 0。 */
int ha_json_members_init(ha_json_members *m, const char *s, size_t n);
/* 取下一个成员。
*
* 出参:
* key —— 键的原始字节 span(未解码,不含引号);可为 NULL
* val —— 值的**完整 span**(未解码);可为 NULL
*
* 返回: 1 = 拿到一个完整成员;0 = 结束。
*
* ★ 本函数**内部会完整跳过一个值**,因此:
* 1. 返回 1 蕴含「这个成员是良构的」(值能独立被 skip)——
* 调用方拿到的 val 一定可解析,不必自己再验一次。
* 2. 游标在返回前已推进到值之后,下一次调用直接看下一个成员。
* (早期版本只报值的**起始位置**、不消费值,迫使调用方自己
* 修正游标 —— 那是个错误的设计:调用方一旦忘了推进,下一个
* 成员就会解析到上一个值,而模糊测试立刻把它暴露了出来。)
*
* ★ 结束时要区分原因:用 ha_json_members_complete() 判断是否正常。
* 返回 0 既可能是「正常扫到 '}'」也可能是「输入畸形」——
* 要复刻 Go 的严格性(畸形 ⇒ 整块作废)就必须能分辨。
*
* ★ 键匹配请用 ha_json_key_eq(大小写不敏感),不要自己 memcmp。
*/
int ha_json_members_next(ha_json_members *m, ha_span *key, ha_span *val);
/* 迭代是否**正常结束**(消费到闭合的 '}')。
*
* 语义:只有在 next() 返回 0 之后才有意义。
* 返回 1 = 对象良构且已完整扫描;0 = 输入畸形(缺 '}' / 尾逗号 /
* 值非法等)。调用方若要复刻 Go 的严格性,应要求它为 1。
*/
int ha_json_members_complete(const ha_json_members *m);
/* 大小写不敏感地比较键 span 与 ASCII 字面量。返回 1/0。
*
* ★ 必须用它而不是 memcmp:Go `encoding/json` 的键匹配**大小写不敏感**,
* 实测 `{"delta":{"CONTENT":"up"}}` 能取出 content="up"。
* 逐字节比对会静默漏掉这类输入。 */
int ha_json_key_eq(ha_span key, const char *name);
/* ==================== 取值(extract) ==================== */
/* 字符串解码的**写入回调**。
*
* ★ 为什么用回调而不是「分配缓冲返回」:本库不 malloc。调用方把自己的
* Go 侧 buffer / 栈缓冲 / 直接写目标的位置交给本库,解码结果逐个 rune
* 以 UTF-8 字节写入 —— 非法序列按 Go 语义替换为 U+FFFD。
*
* ★ 为什么按 rune 而不是按字节:`\uXXXX` 可能产生多字节 rune(含代理对
* 合成的 4 字节 emoji),调用方不该关心编码细节。
*/
typedef void (*ha_json_sink)(void *ctx, const char *utf8_bytes, size_t len);
/* 把字符串值 span(raw 形式,含转义)解码并经 sink 输出。
*
* 出参 out_len:解码后的字节总数(便于调用方预分配 / 校验)。
* 返回 0 = 原始 span 含**语法错误**(如 \u 后不是 4 位十六进制)。
*
* 非法 UTF-8 处理:与 Go `encoding/json` 一致 —— 每个非法字节一个 U+FFFD
* (**不是**按整个序列丢弃)。见真值表 §2.8。
*/
int ha_json_decode_string(ha_span raw, ha_json_sink sink, void *ctx,
size_t *out_len);
/* 把字符串值 span 解码进调用方提供的缓冲(不足则失败,不截断)。
*
* 返回写入的字节数;缓冲不足时返回 (size_t)-1 且不写。
* 适合长度已知且不关心「只需长度」的场景。
*/
size_t ha_json_decode_string_into(ha_span raw, char *out, size_t out_cap);
/* 读整数(仅接受 JSON 整数语法,可选负号;不允许小数点/指数)。
*
* ★ 与 Go 的对应关系:Go 里 `int` 字段会接受 `1e2`(=100)与拒绝 `1.5`;
* 本函数**只认纯整数**,指数/小数由调用方按「类型不匹配 ⇒ 整块作废」
* 语义处理(见真值表 §2.2)。这样职责清晰:本层只回答「这是不是整数」。
*
* 返回 1 = 成功且 *out 已写;0 = 不是合法整数。
* 溢出返回 0(与 Go 报错等价)。
*/
int ha_json_get_int(ha_span raw, long long *out);
/* ==================== 字符串取值的便捷路径 ==================== */
/* 在对象 span 内取键 name 的字符串值,解码进 out(NUL 结尾)。
*
* 返回:解码后字节数(不含结尾 NUL);键缺失 / 类型不是字符串 / 缓冲不足
* 返回 (size_t)-1。out 在成功时保证 NUL 结尾。
*
* 便捷函数:内部走 members 迭代 + key_eq + decode,适合调用方只取一两个键
* 且不需要「类型不匹配 ⇒ 整块作废」细节的场景。
*/
size_t ha_json_object_get_string(ha_span obj, const char *name,
char *out, size_t out_cap);
/* 在对象 span 内取键 name 的整数值。
* 返回 1 = 成功;0 = 键缺失 / 非合法整数 / 溢出。 */
int ha_json_object_get_int(ha_span obj, const char *name, long long *out);
#ifdef __cplusplus
}
#endif
#endif /* HA_JSON_SCAN_H */