仓颉语言开发踩坑记录--基于仓颉语言实现的 Harness实战
本文基于仓颉语言实现的 Harness实战,总结实现过程的踩坑记录。最终完成了325 个单元测试全绿(4 包 30+ 测试类,./scripts/test.sh 一键运行,自动切动态链接配置)+ 15 个 PTY 集成测试场景(49 断言)(python3 scripts/tui_pty_test.py,伪终端驱动真实 TUI),覆盖全部 14 个内置工具 + Agent 核心 + TUI 渲染/事件 + 基础设施。
- 版本:仓颉 SDK 1.0.5 · stdx 1.0.5.1
- 来源:cjh 项目(仓颉实现的终端 AI coding agent)28 轮开发、300+ 单测、双平台(Linux + Windows 交叉)构建中真实踩到并修复的坑
- 性质:每个坑都对应一次真实的线上事故、一次编译失败、或一次测试不通过,不是"听说"。文中解法均为已验证的生产方案。
- 阅读建议:字符串/UTF-8(第一章)与 lambda/异常并发(第三、四章)是最高危坑群,建议先读;工具链/构建(第六章)做跨平台或发布前必读;最后一章是全量清单速查。
项目开源地址:
https://gitcode.com/qq8864/cjh
https://github.com/yangyongzhen/cjh
欢迎体验并提供宝贵意见。

〇、写在前面
仓颉是一门很新的语言,标准库(std / stdx)在 1.0.x 阶段还有大量"文档没写、实现和你预期不一样"的地方。更麻烦的是,这些坑分两类:
- 编译期就报错的:好办,报错信息有时还会误导你(比如把语法坑报成"无法推断泛型")。
- 编译期安静通过、运行期才炸的:这才是最危险的。
String[0..N]切到中文中间抛异常、StringBuilder.append(Byte)把字节当十进制整数输出、spawn任务里未捕获异常只杀线程不杀进程、裸Mutex.lock()临界区抛异常后锁永久泄漏——这些编译期全绿,运行到特定数据/特定时机才暴露,而且往往表现成"进程活着但像死了"这种反直觉现象。
本文按"坑群"组织,每个坑给出现象 → 根因 → 解法 →(关键坑附)排查方法,并尽量给出可复用的防御性写法。文中代码均为仓颉 1.0.5 可编译片段。
约定:文中
§引用的是 cjh 仓库内docs/开发文档与踩坑记录.md的原始事故记录章节号,方便回查完整上下文。
一、字符串与 UTF-8:最高危坑群
仓颉 String 的底层是 UTF-8 字节序列,但它的 API 表面(s[i]、迭代器、StringBuilder.append)会让人误以为自己在操作"字符"。这是整个项目里事故密度最高的一类,且全部是运行期坑。
1.1 s[i] 与字符串迭代返回的是 Byte,不是字符
现象:想取字符串第 i 个字符、或逐字符处理时,拿到的是 Byte(即 UInt8,一个 UTF-8 字节),不是码点、不是 Rune。
let s = "好" // UTF-8: E5 A5 BD,s.size == 3(按字节!)
let b: Byte = s[0] // b == 0xE5,不是"好"
for (b in s) { // 迭代同样给 Byte
if (UInt32(b) > 127) { ... } // 中文/emoji 每个字节都 >127
}
关键认知:s.size 对中文返回的是字节数不是字符数(72 个 CJK 字符 size == 216)。所有"按长度判断/按字符处理"的逻辑,只要涉及中文就会错。
解法:
- 要码点:
s.toRuneArray()得到Array<Rune>,再逐码点处理。 - 要按字节:直接
s[i],但比较字面量要写0x41u8(见 §5.2),别写'A'。 - 渲染终端、算列宽时,不要按字节截断,用码点迭代自然截断到列宽(见 §1.2)。
1.2 String[0..N] 按字节切片且校验 UTF-8 边界
现象(这是项目里代价最高的一次运行期事故,v1.3.5):line[0..cols] 按字节截断,切点落在中文多字节字符中间时,抛 IllegalArgumentException: Invalid utf8 byte sequence。
根因:仓颉切片 s[a..b] 的 a/b 是字节下标,且切片后会校验切点是否落在合法 UTF-8 字符边界。切点落在多字节中间 → 抛异常。中文密集内容上,任意一个固定字节阈值都可能落在字符中间。
事故路径(复现链):compaction 把历史压成 673 字节的中文摘要 → summarizeForMemory() 里 task[0..60],第 60 字节是 0x8B(CJK 字节中间)→ 抛异常 → 该调用在 TUI spawn 收尾块、try/catch 之外求值 → 收尾线程静默死亡(见 §3.1)→ 状态复位永不执行 → 界面永久卡在 Streaming…。短消息不切片所以不复现,属于"偶发"。
实测边界(SDK 1.0.5):对 216 字节中文串,[0..18]/[0..45]/[0..60](都是 3 的倍数 = 字符边界)正常,[0..20] 抛异常。
解法:统一用按字符边界回退的截断工具(回退到最近的合法边界,不抛异常),禁止裸 String[0..N] 切任意文本:
// cjutil.truncateUtf8:maxBytes 处若不是字符边界,向前回退到边界,绝不抛异常
let head = truncateUtf8(text, maxBytes)
// 取尾部同理
let tail = tailUtf8(text, maxBytes)
防御原则:每次写 s[a..b] 前先问一句——这个切片点会落在中文中间吗? 会,就必须走边界安全截断。
1.3 StringBuilder.append(Byte) 按"十进制整数"输出(隐蔽度 No.1)
现象(曾把核心编辑工具彻底打废):edit 任何一次替换,文件内容变成一串数字(let x = 1 变成 101010810111632...)。
根因:
var result = StringBuilder()
for (i in 0..text.size) {
result.append(text[i]) // text[i] 是 Byte
}
StringBuilder.append(Byte) 不是"把这个字节当字符追加",而是把这个 UInt8 值当成一个十进制整数打印。字节 0x6C('l')被追加成字符串 "108"。于是一行代码的每个字节都被替换成它的十进制值。
为什么隐蔽:ASCII 测试里如果恰好不触发、或测试只断言"成功"不比对内容,这个 bug 可以潜伏很久。它是"输出看起来是数字但不是你想的数字",非常反直觉。
解法:拼接/替换字节时必须整段 String 切片,禁止逐字节 append:
// 正确:整段切片追加(UTF-8 安全)
result.append(text[lastCopy..i])
// 错误:逐字节 append(把字节当十进制整数)
// result.append(text[i])
同源坑:String(Rune(byte)) 逐字节重建字符串(§1.4)、Rune(Int32(buf[i])) 拼串(§1.5)——本质都是"把一个 UTF-8 字节当成独立码点"。
1.4 String(Rune(byte)) 逐字节重建 → 摩尔化(长度翻倍)
现象:把字符串按字节拆开再 Rune(单字节) 拼回去,得到的串不是原串,长度翻倍,中文全乱。
根因:Rune(0xE4) 把 0xE4 当成一个独立码点,再编成 UTF-8 时按 Latin-1 语义占 2 字节。原"好"(E5 A5 BD,3 字节)拆成三个独立码点重建后变成 6 字节的乱码。
解法:重建字符串必须整段(String.fromUtf8(bytes) 或 String 切片),禁止逐字节 append(Rune(...))。
1.5 字节流 → String:禁止逐字节 String(Rune(byte))(乱码根因,已修 3 处)
现象:工具输出 / 粘贴中文显示成 é读... / 好 一类 Latin-1 乱码。
解码定性:每个 UTF-8 字节被当成一个独立 Latin-1 码点。“读”=E8 AF BB → U+00E9 U+00E8 U+00AF U+00BB(é è ¯ »)。
根因(同一类 bug 在 3 条独立读取路径上各出现一次——修一类解码 bug 时务必 grep 全仓所有"字节→String"路径):
- bash 会话
readUntilMarker:for i in 0..n { sb.append(String(Rune(Int32(buf[i])))) } web_search.urlDecode:%XX解码后同样逐字节Rune(byte)- 终端 bracketed paste
readBracketedPaste:sb.append(Rune(Int32(b.read(0))).toString())
为什么"打字中文正常、只有粘贴乱码":手动输入走 readKey 的多字节分支(正确按 UTF-8 长度累积 + String.fromUtf8 整段解码),而 bracketed paste 是独立读取路径,走了逐字节拼接。
解法:所有字节流构造 String 的路径——累积字节到完整 UTF-8 字符边界,再整段 safeFromUtf8 解码:
// safeFromUtf8(cjutil 通用工具):非法字节容错为 U+FFFD,不抛异常
let text = safeFromUtf8(pendingBytes)
跨读取块时维护 pending 字节:每轮在"最后一个完整字符边界"处解码,不完整的尾部字节留到下一块。ASCII marker(纯单字节)落在已解码段,搜索不受影响。
防御:grep "Rune(Int32" / "Rune(Int64" / "Rune(b" 后跟 .toString()` 拼接 = 乱码嫌疑路径,逐一核对。
1.6 本章小结
| 坑 | 一句话 | 防御 |
|---|---|---|
s[i]/迭代给 Byte | 不是字符 | 要字符用 toRuneArray() |
s[0..N] 校验边界 | 切中文中间抛异常 | truncateUtf8/tailUtf8 |
append(Byte) | 按十进制整数输出 | 整段 String 切片 |
String(Rune(byte)) | 摩尔化翻倍 | 整段 fromUtf8 |
| 字节流拼串 | 逐字节=乱码 | 字符边界 + safeFromUtf8 |
二、语法细节:编译期就报错的坑群
这些坑都有编译报错,但报错信息经常误导(报成"无法推断泛型"而不是"你没写对 lambda"),排查时容易走弯路。
2.1 参数与调用:无默认参数、无命名参数
| 坑 | 现象 | 解法 |
|---|---|---|
| 无默认参数 | func f(a: Bool = false) 编译错 | 提供重载,或调用处显式传参 |
| 普通参数无命名 | f(x: 1) 报 target is not a named parameter | 普通参数按位置传;只有 p!: 声明的参数才支持命名 |
本文 §4.2 的
loadAll加参数时就是被"无默认参数"逼着把Option<...> = None的写法改成显式传参的。
2.2 lambda 写法与捕获(高频三连坑)
(a)参数列表写法(写错编译报"无法推断泛型"之类的迷惑错误):
let f1 = { => doSomething() } // 零参:{ => },不是 { () => }
let f2 = { x => x + 1 } // 单参:无括号
let f3 = { a, b => a + b } // 多参:逗号分隔,无括号
| 错法 | 报错 | 正解 |
|---|---|---|
{ () => ... } | 编译错 | { => ... } |
{ (a, b) => ... } | 编译错 | { a, b => ... } |
(b)捕获可变局部变量(var)的 lambda 必须"直接调用",不能作为参数传:
var hdrs = makeHeaders()
var body = makeBody()
// 报错:lambda capturing mutable variables needs to be called directly
runWithBudget(..., { => tr.request(path, hdrs, body) })
这是项目里反复咬人的规则(预算竞速、工具 walk 回调都踩过)。解法分两种场景:
- 能传参的场景:传参前用
let接住不可变快照,lambda 只捕获let局部量:let reqHdrs = hdrs // 不可变快照 let reqBody = body.toJsonString() runWithBudget(..., { => tr.request(path, reqHdrs, reqBody) }) - 必须回调的场景(
Directory.walk、异步回调):捕获let绑定的可变对象引用(如let state = WalkState(),回调里改state.xxx)——state本身是let,捕获合法,改的是对象内部。这是仓颉里"绕开捕获限制"的标准模式。 - 实在不行:用类静态成员中转(早期
SessionState.current就是这么干的,不推荐,有并发隐患)。
(c)闭包内 var 自增:闭包内直接对捕获的 var 自增同样受限——需要自增计数的回调,把计数器放进一个 let 引用的对象里(如 var n = 0 改为 let st = Counter() + st.n += 1)。
2.3 控制流与表达式
| 坑 | 现象 | 解法 |
|---|---|---|
if (let x <- opt) 绑定仍是 Option | 取出后还要 .getOrThrow(),容易忘 | 必须 if (let Some(x) <- opt) 才解包 |
| 复合 if-let 不能逗号/分号 | if (let a <- x, let b <- y) 编译错 | 用 && 连接:if (let a <- x && let b <- y) |
?? 优先级低 | a ?? "" == "1" 被解析成 a ?? ("" == "1") | 加括号:(a ?? "") == "1" |
| if-else 分支类型不一致 | else 分支的表达式值被丢弃,告警/报错 | let _ = expr 显式丢弃 |
无 getOrElse | Option 取值 | opt ?? default;opt.getOrThrow() 取强值 |
| 集合无 map | ArrayList 没有 map | for 循环 + add |
2.4 类型与字面量
| 坑 | 现象 | 解法 |
|---|---|---|
Byte 是 UInt8 别名 | 字面量 0x41b 被解析成十六进制 0x41B(超范围) | 显式写 0x41u8 |
无 [a, b] 数组字面量 | 构造数组 | Array(n, repeat: v) 或 ArrayList |
JsonValue 形态判断 | ==/!= 比不了 | 必须 match (v.kind()) { case JsonKind.JsString => ... } |
| enum 模式参数数量 | 给 PatchOp.LineAnchor 加参数后,旧 case LineAnchor(_) 报 “enum pattern’s parameters size is wrong” | 全仓搜该模式补齐 LineAnchor(_, _)——漏一处即编译失败 |
&var 不能取地址 | CPointer(&mode) 语法错 | LibC.malloc 缓冲 + 手写小端读写;FFI 指针参数统一 CPointer<UInt8> |
按位取反是 ! | 位运算想写 ~x | 仓颉一元 ! 即按位取反(Bool 的 not 是 !);位运算用 &/|/^ |
| 字符串/整数溢出检查 | hash * 16777619(UInt32)抛 OverflowException: mul | 仓颉默认开启溢出检查——哈希/位运算全程 UInt64 运算 + 每步 & 0xFFFFFFFF 截断 |
溢出这条是 hashline 工具"每次调用必抛异常"的根因:FNV-1a 拿
UInt32直接乘,仓颉默认检查直接炸。写哈希/加密/位运算时先假设所有运算都可能溢出。
2.5 FFI 细节
| 坑 | 现象 | 解法 |
|---|---|---|
| foreign 函数名必须匹配 C 符号 | cjGetenv 链接期 undefined reference | 名字要和 libc 符号一致(getenv),不能随意起 |
| 类内不能有 static foreign | @C public foreign static func 语法错 | foreign 顶层声明 + 类内静态方法包装 |
CPointer → CString | 构造 CString | CString(ptr)(unsafe 上下文) |
Void 类型未导入 | FFI 指针用 Void 报错 | 统一用 CPointer<UInt8> |
2.6 块注释不能嵌套
现象:注释里写了 **/*(想表达 glob 字面量"任意/任意"),触发 /* 被解析为嵌套注释开始,报 illegal character。
解法:块注释(/* ... */)内不要出现 /* 序列——写 glob 示例时用反引号代码块或换说法(如 */* 写成 “星号/星号”)。
三、异常与并发:最隐蔽的运行期坑群
这一章的坑全部编译期全绿,运行期才炸,而且炸的形态反直觉(进程活着但界面死了 / 界面死了但 CPU 0%)。是项目里排查耗时最长的部分。
3.1 spawn {} 未捕获异常:只杀任务线程、进程存活、无栈
现象(v1.3.5 僵尸忙态事故的根因之一):TUI 回合早已结束(日志 Agent.run 正常结束),但状态行永久冻结在 Streaming…,之后所有按键都走"打断"分支。
根因:仓颉 spawn 任务内的未捕获异常只终止该任务线程,进程继续存活,且不打印崩溃栈——比崩溃更隐蔽:进程"健康"地活着,只是某段收尾逻辑静默死掉了。
事故链:summarizeForMemory() 的 task[0..60](§1.2)抛异常 → 调用点在 TUI spawn 收尾块、try/catch 之外求值(saveProjectMemory(dir, agent.summarizeForMemory()),实参先于 try 求值)→ 收尾任务死亡 → setAgentRunning(false) 永不执行 → 僵尸忙态。
防御(三条,缺一不可):
- spawn 任务里收尾关键路径(状态复位、队列出队、资源关闭)必须 try/catch 兜底,禁止让 spawn 闭包裸奔。
- 关键实参先
let接住再进 try——f(x, g())中g()抛异常时 try 管不到(实参在 try 外求值):let summary = agent.summarizeForMemory() // 先接住 try { saveProjectMemory(dir, summary) } catch (e: Exception) { ... } setAgentRunning(false) // 必达 - spawn 内未捕获异常会额外向 stderr 打报告(污染 TUI 输入框)——用"结果载体"收住异常(如
BudgetOutcome对象),task 闭包不裸抛。
排查:spawn 静默死无法 attach 活进程取栈(进程早已"健康")→ 只能靠日志窗口 + 文件 mtime 二分定位死亡区间,再用最小复现验证假设。本次事故正是靠 .4 会话文件 mtime vs 记忆文件 mtime 把死点钉在 store.save 之后、setAgentRunning(false) 之前。
3.2 裸 Mutex.lock():临界区抛异常 = 锁永久泄漏 = 全停摆
现象(v1.3.11,51 轮 43 分钟会话后):TUI 主循环停摆(按键无响应、画面冻结),进程存活但终端停留 raw(restoreTerminal 没执行);现场 4 个未回收僵尸 + 1 个泄漏约 2 小时的 bash 会话。
gdb 定性(关键,可复用):7 线程全 idle/park,主线程栈 CJ_CJThreadMpark ← CJ_ProcessorStopWithLastCheck,abstime=0x0(无限等待)——这是仓颉运行时的**“无可运行协程” stop 路径**(调度器发现无就绪协程,进入停止检查并无限休眠)。不是 OS 级死锁(无两线程互持锁),不是忙等(CPU 0%)。
根因:TuiApp 多处裸 lock(); ...; unlock(),持锁临界区内(renderInner 读 outputView.content()、requestApproval 置审批态等 9 处)任何异常抛出 → unlock() 不执行 → 锁永久泄漏。outputLock 被主循环每帧 render 与 agent 线程流式回调双端共享——一旦泄漏,两个任务都 park 在 lock() 上 → 无可运行协程 → stop 路径。长会话 + 超大 OutputView 缓冲把"临界区抛异常"概率放大到命中。
解法:
- 所有
lock()必须try/finally守护:this.outputLock.lock() try { // 临界区(可能抛异常的操作) } finally { this.outputLock.unlock() } - 主循环每帧 try/catch 兜底 +
finally → restoreTerminal——单帧异常不得终止事件循环,终端 raw 恢复放 finally(崩溃也还原 ECHO)。 - 审计方法:
grep "\.lock\(\)",后一行不是try {的就是嫌疑;重点看"持锁期间调可抛异常方法"的临界区。
三种"进程活着但死了"的鉴别(高频排查场景):
| 特征 | 定性 | 根因 |
|---|---|---|
| CPU 100% + 采样落业务协程 | 忙等自旋 | 计数空循环假 sleep(§3.4) |
CPU 0% + 全线程 park + CJ_ProcessorStopWithLastCheck + abstime=0x0 | stop 路径 | 某协程 park 在无超时受管原语(多为泄漏的锁,§3.2) |
| CPU 低 + 单线程阻塞在 futex | 普通锁等待 | 持锁线程卡死/未释放 |
3.3 裸 socket 无超时:connect 挂死 = 请求永久卡死
现象(v1.3.6,首个 LLM 请求 442s 无响应):TUI 首个请求发出后状态行永久冻结,看门狗 8 次 interrupt 全部无效。与 §3.1 的关键区别:日志里没有 run 正常结束——run 线程自己还卡在第一个 LLM 请求里。
根因(三层叠加):
- 仓颉裸
TcpSocket.connect没有超时参数(1.0.5 实测):DNS/TCP/TLS 任一阶段挂死时,readTimeout不起作用(它只在"数据可读性"上生效,覆盖不到 connect 本身);write()大 payload 经代理也可能挂死。 abort()打不断 connect:abort()只做close(this.socket),而socket在 connect 成功返回后才赋值——connect 挂死期间socket == None,close(None)空转。中断信号传得到、关不到还没建好的连接。- 请求阶段整体无可中断超时:connect/写请求/读响应头三阶段都同步调用,任一挂死 = run 永久挂起。
解法(cjutil.runWithBudget 预算竞速原语,已下沉到传输层):
// spawn 子任务执行 task,主线程每 pollMs 轮询:超预算抛 BudgetTimeoutException、
// 被 cancelChecker 取消抛 BudgetCancelledException、task 异常原样重抛
runWithBudget(label, budgetMs, pollMs, isCancelled, { => tr.connect(...) })
connect()整体包预算(预算 = idleTimeout,预算内重试一次——首连超预算可能是 DNS 瞬态抖动),request()的 write 同样包(30s)。- 关键设计:预算下沉进
HttpStreamClient内部,而不是包在 openai.cj 的每个调用点——否则 anthropic/ollama/未来新协议的同构调用点会漏网(首版就漏了,v1.3.7 返工)。新协议只要复用传输层就天然有界可中断。 - 回归测试:用
192.0.2.1(RFC 5737 TEST-NET,设计上不可路由,SYN 被丢弃)当黑洞——裸 connect 实测 8s+ 挂起(=事故形态),idleTimeout=1s时connect()~2s 内抛BudgetTimeoutException。
3.4 节流"计数空循环"假 sleep:后台协程忙等饿死 UI
现象(v1.3.10):远程会话 cjh 画面冻结无法输入,但进程存活、CPU 100%(累计 8000+s)、对应 sshd Send-Q=0(SSH 没断线)——“进程活着却像死了”。
根因:异步日志 sink 的空闲节流写成了纯整数忙等:
internal func sleepMs(ms: Int64): Unit {
var i: Int64 = 0
while (i < ms * 1000) { // 只自增整数,不消耗任何时间!
i += 1000
}
}
它跑在仓颉主 worker 线程上,满速自旋把唯一的用户态 worker 占满,饿死 TUI 主循环(5ms 轮询 readKey/render)。
排查路径(可复用):
ps -o etime,time,%cpu -p PID:CPU 100% + 累计时间巨大 → 自旋而非阻塞ss -tnp:sshdSend-Q=0→ 排除 SSH 链路strace -p PID -f -c:futex + epoll_wait(timeout=0)高频 → 忙轮询特征gdb -p PID -batch -ex "bt"多次采样:热点反复落LogSink.sinkLoop→ 锁定
经验:
- 节流/退避绝不用计数空循环假装 sleep——它不耗时,等于没有;用 runtime 的
sleep(Duration.millisecond * ms)。 - 后台协程若跑在主 worker 上,其忙等会饿死 UI 主循环——协程空转比业务线程空转危害大得多。
3.5 退出路径:terminate 后必须 wait,长生命周期后台必须显式关闭
现象(v1.3.11,与 §3.2 同批):停摆现场 4 个未回收僵尸 + 1 个泄漏约 2 小时的会话 bash(stderr→/dev/null)。
根因:
killSession只terminate(force:true)(SIGKILL)不wait()→ 每个超时/关闭的会话进程都留一个未回收僵尸。runTui退出路径从不关闭BashTool会话、断开 MCP、Log.shutdown()——长生命周期后台(Logsink 协程、bash 管道读协程)退出后继续残留;主进程若先停摆,泄漏的 bash 会一直持着 stdout 管道(读协程阻塞在 read、bash 收不到 EOF)。
解法:
terminate后必wait(timeout:5s)回收僵尸(加testReapedCount()观测)。runTui的finally退出清理:BashTool.close()+mcpManager.disconnectAll()+Log.shutdown(),各独立 try/catch(一个清理失败不挡住其余)。- 别依赖"进程退出自动收子进程"——主进程若先停摆,"自动收"永远不会发生。
四、标准库 API:文档没写、实现和预期不一样的坑群
4.1 Directory.walk:非递归 + 回调返回 false 终止整个遍历
现象(曾让 grep/glob 的目录搜索"只搜第一层"且"遇 .git 整个搜索停止"):直觉以为 Directory.walk 递归遍历子树。
实测(1.0.5):
- 非递归:只遍历直接子项,不进子目录。
- 回调返回
false终止整个遍历(不是"跳过当前项")——想忽略.git却return false,等于把后面的兄弟目录全砍了。
解法:
// 忽略某目录:return true(继续遍历),自己跳过该目录的深入
// 递归:显式实现——回调里 isDirectory 时再 walk 一层
Directory.walk(dir, { fi =>
if (fi.isDirectory() && !shouldSkip(fi.path)) {
walkRecursive(fi.path.toString()) // 显式递归
}
return true // 永远 true,终止权不交给单点判断
})
另一个坑:Directory.list 不存在,目录遍历只有 Directory.walk 回调式。
4.2 无 map/无 filter 的集合:for 循环是唯一解
ArrayList 没有 map/filter/reduce 高阶方法(1.0.5)。所有集合转换都是 for 循环 + add。写代码前先接受这个事实,别反复找不存在的 API。
4.3 StringBuilder.append 的重载陷阱(汇总)
| 调用 | 行为 |
|---|---|
append(String) | 正常追加字符串 |
append(Byte) / append(UInt8) / append(Int*) | 按十进制整数输出(§1.3) |
append(Rune) | 正常追加字符 |
追加字节流只能整段 String 切片(append(text[a..b]))或 append(Rune),逐字节 append(byte) 必炸。且 append 返回 Unit,不可链式(sb.append(a).append(b) 编译错:undeclared identifier 'append')。
4.4 文件系统:OpenMode.Append 会创建文件、Directory.create 不幂等
| 坑 | 现象 | 解法 |
|---|---|---|
OpenMode.Append 自动建文件 | append 到不存在文件"成功"了(与工具 spec “Fails if not exist” 不符) | 写前显式 exists(path) 校验 |
Directory.create 不幂等 | 对已存在目录抛 FSException(不是忽略) | 先 exists 检查或先删;测试里每个用例独立目录/先清理 |
| 无递归删除 API | 清测试目录 | bash rm -rf(测试辅助函数)或逐层删 |
测试规范直接受此坑影响:每个用例用独立临时目录(
/tmp/cjh_ut_<name>),开头先rm -rf再create,避免用例间残留。
4.5 时间:toUnixTimeStamp(大小写坑)+ 会话 ID 碰撞
DateTime的时间戳方法是toUnixTimeStamp(): Duration——Stamp的 S 大写(写成toUnixTimestamp找不到方法)。秒.毫秒精度的 ID 会碰撞:save与saveFork同毫秒调用生成相同会话 ID,后者覆盖前者(parent 链错乱)。解法:追加进程内静态计数器(秒.毫秒.计数器)。
4.6 String 工具函数盘点(容易踩错的 API 面)
| 想要的 | 仓颉 1.0.5 的做法 |
|---|---|
| 按字符遍历 | s.toRuneArray() 后 for |
| 按字节遍历 | for (b in s)(给 Byte) |
| 大小写 | toLower()/toUpper() |
| 子串 | s[a..b](字节下标,校验 UTF-8 边界) |
| 查找 | indexOf/lastIndexOf/contains/startsWith(按子串) |
| 分割 | split("\n", -1)(limit 传 -1 保留尾部空串;不传/传 0 会丢尾部空段) |
| 替换 | replace(old, new) 全量替换(无单次、无范围) |
| 环境 | readEnv(name)??默认(std.env);EnvFfi.homeDir() 取主目录 |
split的 limit 参数是隐蔽坑:"a\n\n".split("\n", -1)给["a","",""];不带 -1 尾部空串被吞——做"按行处理"时行尾空行会悄悄消失。
4.7 条件编译:@When[os == "..."] 与内置 os 条件
| 坑 | 现象 | 解法 |
|---|---|---|
os 是内置编译条件 | 平台分支别自定义 cfg | @When[os == "Windows"],值:Windows Linux macOS HarmonyOS(大小写敏感),无需 --cfg |
@When 只能用于声明 | 函数体里写 @When[os == "Windows"] { return X() } 报 “expected declaration” | 两个同名函数各带 @When 互斥(编译期只留一个);类声明/foreign 同样支持 |
验证平台后端是否真被排除(交叉编译后):对产物 strings 查符号——无 tcgetattr/termios(POSIX 后端排除)、有 GetConsoleMode/ReadConsoleInputW(Windows 后端编入)。单向编译通过无法证明另一平台后端被排除。
4.8 测试框架(@Test/@TestCase/@Assert)坑
| 坑 | 现象 | 解法 |
|---|---|---|
@Assert 对 enum 期望值泛型推断失败 | @Assert(actual, TrustResult.Added) 报 unable to infer generic argument of this function(Int/String/Bool 期望值正常,enum 必炸) | match 转 Bool 再断言:@Assert(match(actual) { case X => true; case _ => false }, true) |
@Assert 不支持三参消息形式 | @Assert(x, y, "msg") 报 Test framework: macro failed to expand. | 只支持两参;要消息就拆成多个断言 |
| 测试宏展开报错定位偏移 | 报错指向宏展开后的代码(/* 395.15 */ 行号) | 看 note: the error occurs after the macro is expanded 里的源文件:行号 |
| 根包测试位置 | executable 根包(如 cjh)不能被 cjh.tests 导入 | 根包函数测试必须放 src/core_funcs_test.cj(package cjh);工具/子包测试放 src/tests/(package cjh.tests) |
| 测试进程 cwd | 相对路径依赖执行位置 | scripts/test.sh 固定 cd 仓库根;测试里用"常见开发路径兜底 + exists 探测"双保险 |
静态链接下 cjpm test 崩溃 | 默认 --static 配置跑测试 double free(SIGSEGV)(运行时/测试框架组合 bug,发布二进制正常) | 用 scripts/test.sh 自动切动态配置跑完恢复静态;别直接 cjpm test |
回归测试方法论(bug 修复必配):
- 先写复现测试(红)再修复(绿)——测试用事故真实数据钉死(如"byte60=0x8B 的中文串"),前提自证(旧逻辑下断言失败)+ 修复后全绿。
- 并发/退出路径类 bug 用观测器断言(
testReapedCount()、getCompactionMode()),不测私有实现。 - 工具体走
execute(args)公共 API 断言ToolResult(isError/content),不测内部结构。
五、工具链与构建(cjpm / 交叉编译 / 依赖)
5.1 环境:cjpm 不在默认 PATH
仓颉工具链(cjpm/cjc)默认不在 PATH,每个新 shell 先:
source /opt/cangjie/cangjie/envsetup.sh
CI/脚本里漏掉这一步 = cjpm: command not found。
5.2 静态链接是默认配置,测试必须动态
cjpm.toml 默认 --static(发布要静态自包含二进制),但静态配置下 cjpm test 会 double free 崩溃(运行时/测试框架组合 bug,发布二进制正常)。交付门禁因此必须走 scripts/test.sh(自动切动态配置跑测试、结束后恢复静态)。直接 cjpm test = 偶发 SIGSEGV 冤案。
5.3 cjpm add 不存在:依赖只能手工维护 cjpm.toml
cjpm add <pkg> 报 unknown command 'add'(1.0.5 无 add 子命令)。加依赖 = 手写 cjpm.toml 的 dependencies 段。独立仓库引用已发布的库用 git tag 依赖(别用本地 path——独立发布后 path 失效):
[dependencies]
cjutil = { git = "https://gitcode.com/<org>/cjutil.git", tag = "v0.1.0" }
5.4 多包(monorepo)测试:根包测试不覆盖 libs/ 下的独立包
libs/ 下每个库(cjlog/cjutil/cjterm…)是独立包(各自 cjpm.toml),根目录 cjpm test / scripts/test.sh 只跑根包,不跑库包测试。改库必须:
cd libs/<lib> && cjpm test
这是"根门禁全绿但库包回归"的盲区——动
libs/任何文件后必须单独跑该库测试。
5.5 交叉编译:Windows 产物与平台验证
- 交叉编译目标
x86_64-pc-windows-gnu,产物在target/x86_64-pc-windows-gnu/。 - 平台后端验证(§4.7):
strings查产物符号——Linux 产物应无GetConsoleMode、Windows 产物应无tcgetattr。单向编译通过 ≠ 另一端后端被排除。 - 双平台发布包 = 两个 target 各构一次 + 打 zip(
scripts/dist.sh)。
5.6 编译警告噪音管理
1.0.5 常见噪音:--static-libs deprecation 警告、deprecated 方法警告(ReentrantMutex 等)。cjpm build 成功与否只看 error,grep error 时要把 deprecated 警告的上下文行滤掉(警告行里常带 | 和 ^ 指向符,容易误判)。
5.7 测试宏展开输出淹没有用信息
cjpm test 失败时输出里宏展开体占 99%(几千行 /* 行.列 */ 展开代码),真正的 error 行被淹没。抓法:
cjpm test 2>&1 | grep -E "^error|error:|error generated"
# 看宏展开前的源位置:
grep -n "the error occurs after the macro is expanded" out.log
六、排查方法论(跨坑群的通用套路)
按"症状 → 手段"组织,全部来自项目真实排查过程。
6.1 "进程活着但像死了"三分类(最高频症状)
| 特征 | 定性 | 根因方向 | 手段 |
|---|---|---|---|
CPU 100% + ps time 巨大 | 忙等自旋 | 计数空循环假 sleep(§3.4) | ps -o etime,time,%cpu → strace -f -c(futex+epoll(0) 高频)→ gdb bt 多点采样找热点协程 |
CPU 0% + 全线程 park + gdb 栈 CJ_ProcessorStopWithLastCheck + abstime=0x0 | 仓颉 stop 路径(无可运行协程) | 泄漏的锁(§3.2)/ 无超时受管原语 | gdb -p PID -batch -ex "thread apply all bt":主线程停在此处 = 调度器放弃;再找谁 park 在 lock() |
| CPU 低 + 单线程卡 futex | 普通锁等待 | 持锁方卡死/未释放 | 同上,定位持锁线程 |
gdb 读栈要点:abstime=0x0 表示无限等待(区别于超时等待);CJ_CJThreadMpark 是协程 park 帧。
6.2 spawn 静默死:进程已"健康",attach 取栈无意义
spawn 异常只杀任务线程(§3.1),事后进程存活、栈正常——活进程抓不到死点。定位靠:
- 日志窗口二分:最后一条日志 vs 预期应出现的日志,缩小死亡区间。
- 文件 mtime 锚点:死亡区间内"应写入而未写入"的文件(如
.4 会话文件 mtime有、记忆文件 mtime无 → 死在两者之间)。 - 最小复现:怀疑点用最小脚本复现(如
task[0..60]直接抛异常的最小串)。
6.3 乱码排查:先精确解码定性,再找路径
看到乱码别猜——逐字节定性:
- 每字节一码点(
é è ¯ »)→ 逐字节Rune(byte)拼串(§1.5),grep 全仓所有字节→String 路径逐一核对(同一 bug 常在 N 条独立路径各出现一次)。 - 整体二次编码 → 编码转码错误,查
fromUtf8/toUtf8误用。 - 验证技巧:ASCII 全过 ≠ 中文对——所有字节/字符串逻辑的测试必须含中文样本(
"好"/"读")。
6.4 挂死排查:区分"数据可读性超时"与"连接建立超时"
readTimeout 只管"已建连后的数据可读性",管不到 connect(§3.3)。挂死先分层定性:DNS?TCP SYN?TLS 握手?哪一层没返回就是哪层要预算(runWithBudget 包在对应层)。验证用 192.0.2.1(TEST-NET,SYN 被丢弃,天然黑洞)。
6.5 "偶发"bug 的放大器思维
多数运行期坑(边界切片、锁泄漏)都有放大器:
- 边界切片 ← 长中文文本(短消息不复现)
- 锁泄漏 ← 长会话 + 超大缓冲(临界区抛异常概率放大)
- 僵尸累积 ← 长会话 + 多次超时(1 小时 1 个,短会话看不到)
“偶发”= 放大器没到位,不是概率问题。复现时先找放大器。
6.6 审计式修复:同类 bug 必全仓排查
修一处 bug 前先 grep 同类模式全仓,修完再确认无遗漏。实例:
- 逐字节拼串(§1.5):同一模式在 3 条独立读取路径各出现一次。
- 裸
lock()(§3.2):9 处裸 lock 全改try/finally,审计法grep "\.lock()"后一行非try {即嫌疑。 - enum 模式参数(§2.4):给
PatchOp.LineAnchor加参数后全仓搜旧模式补齐,漏一处即编译失败。
七、全量坑速查清单
| # | 坑 | 章 | 危险度 |
|---|---|---|---|
| 1 | s[i]/字符串迭代给 Byte(size 按字节) | §1.1 | 中 |
| 2 | String[0..N] 按字节切片 + 校验 UTF-8 边界(切中文抛异常) | §1.2 | 高 |
| 3 | StringBuilder.append(Byte) 按十进制整数输出 | §1.3 | 高 |
| 4 | String(Rune(byte)) 逐字节重建 → 摩尔化 | §1.4 | 中 |
| 5 | 字节流拼串必须字符边界 + safeFromUtf8(3 条路径各一次) | §1.5 | 高 |
| 6 | lambda:零参 { => };捕获 var 须直接调用(let 快照/let 引用对象绕行) | §2.2 | 中 |
| 7 | 无默认参数;普通参数无命名(p!: 才有) | §2.1 | 低 |
| 8 | if (let x <- opt) 绑定仍 Option;复合 if-let 用 &&;?? 优先级低 | §2.3 | 中 |
| 9 | Byte=UInt8(0x41u8);JsonValue 形态用 match kind();enum 模式参数数量全仓同步 | §2.4 | 中 |
| 10 | 仓颉默认溢出检查(哈希/位运算 UInt64 + 截断) | §2.4 | 中 |
| 11 | FFI:名字匹配 C 符号;static foreign 须顶层;CPointer<UInt8> | §2.5 | 中 |
| 12 | 块注释内禁 /* 序列 | §2.6 | 低 |
| 13 | spawn 未捕获异常只杀任务线程(收尾必 try/catch;实参先 let 接住) | §3.1 | 极高 |
| 14 | 裸 Mutex.lock() 临界区抛异常 = 锁泄漏 = stop 路径全停摆(try/finally 守护) | §3.2 | 极高 |
| 15 | 裸 TcpSocket.connect 无超时(readTimeout 管不到;预算竞速原语下沉传输层) | §3.3 | 高 |
| 16 | 计数空循环假 sleep 饿死主 worker(用 runtime sleep) | §3.4 | 高 |
| 17 | terminate 后必 wait;退出路径显式关 Log/MCP/会话(独立 try/catch) | §3.5 | 中 |
| 18 | Directory.walk 非递归 + 回调 false 终止遍历(显式递归) | §4.1 | 中 |
| 19 | 集合无 map/filter;StringBuilder.append 返回 Unit 不可链式 | §4.2/4.3 | 低 |
| 20 | OpenMode.Append 自动建文件;Directory.create 不幂等(测试独立目录) | §4.4 | 中 |
| 21 | toUnixTimeStamp(大写 S);会话 ID 秒.毫秒碰撞(加计数器) | §4.5 | 低 |
| 22 | split 尾空串需 limit=-1 | §4.6 | 中 |
| 23 | @When[os == "..."] 只用于声明(两同名函数互斥);strings 验平台后端 | §4.7 | 中 |
| 24 | @Assert 枚举期望值泛型推断失败(match 转 Bool);不支持三参消息 | §4.8 | 中 |
| 25 | 根包测试放 src/core_funcs_test.cj;静态链接下 cjpm test 崩溃(走 test.sh) | §4.8/5.2 | 中 |
| 26 | cjpm add 不存在(手写 toml);独立包测试需 cd libs/<lib> | §5.3/5.4 | 中 |
三条总纲:
- 凡是"按字符"的直觉操作(取/切/拼/数),先确认你在操作的是字节(仓颉 String 底层是 UTF-8 字节)。
- 凡是 spawn / 持锁 / 裸 socket / 进程退出,先假设它会失败(未捕获异常、临界区异常、connect 挂死、僵尸残留),兜底再干活。
- 修一处必 grep 全仓同类(同一模式 bug 总会在 N 条独立路径各出现一次)。
完。本文基于 cjh 项目 28 轮开发、300+ 单测的实战踩坑整理;每条都可回查
docs/开发文档与踩坑记录.md对应章节的完整事故上下文。
更多推荐


所有评论(0)