本文基于仓颉语言实现的 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 BBU+00E9 U+00E8 U+00AF U+00BBé è ¯ »)。

根因(同一类 bug 在 3 条独立读取路径上各出现一次——修一类解码 bug 时务必 grep 全仓所有"字节→String"路径):

  1. bash 会话 readUntilMarkerfor i in 0..n { sb.append(String(Rune(Int32(buf[i])))) }
  2. web_search.urlDecode%XX 解码后同样逐字节 Rune(byte)
  3. 终端 bracketed paste readBracketedPastesb.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 显式丢弃
getOrElseOption 取值opt ?? defaultopt.getOrThrow() 取强值
集合无 mapArrayList 没有 mapfor 循环 + add

2.4 类型与字面量

现象解法
ByteUInt8 别名字面量 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仓颉一元 ! 即按位取反(Boolnot!);位运算用 &/|/^
字符串/整数溢出检查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构造 CStringCString(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) 永不执行 → 僵尸忙态。

防御(三条,缺一不可)

  1. spawn 任务里收尾关键路径(状态复位、队列出队、资源关闭)必须 try/catch 兜底,禁止让 spawn 闭包裸奔。
  2. 关键实参先 let 接住再进 try——f(x, g())g() 抛异常时 try 管不到(实参在 try 外求值):
    let summary = agent.summarizeForMemory()   // 先接住
    try { saveProjectMemory(dir, summary) } catch (e: Exception) { ... }
    setAgentRunning(false)                      // 必达
    
  3. 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 主循环停摆(按键无响应、画面冻结),进程存活但终端停留 rawrestoreTerminal 没执行);现场 4 个未回收僵尸 + 1 个泄漏约 2 小时的 bash 会话。

gdb 定性(关键,可复用):7 线程全 idle/park,主线程栈 CJ_CJThreadMpark ← CJ_ProcessorStopWithLastCheckabstime=0x0(无限等待)——这是仓颉运行时的**“无可运行协程” stop 路径**(调度器发现无就绪协程,进入停止检查并无限休眠)。不是 OS 级死锁(无两线程互持锁),不是忙等(CPU 0%)。

根因TuiApp 多处裸 lock(); ...; unlock(),持锁临界区内(renderInneroutputView.content()requestApproval 置审批态等 9 处)任何异常抛出 → unlock() 不执行 → 锁永久泄漏outputLock 被主循环每帧 render 与 agent 线程流式回调双端共享——一旦泄漏,两个任务都 park 在 lock() 上 → 无可运行协程 → stop 路径。长会话 + 超大 OutputView 缓冲把"临界区抛异常"概率放大到命中。

解法

  1. 所有 lock() 必须 try/finally 守护
    this.outputLock.lock()
    try {
        // 临界区(可能抛异常的操作)
    } finally {
        this.outputLock.unlock()
    }
    
  2. 主循环每帧 try/catch 兜底 + finally → restoreTerminal——单帧异常不得终止事件循环,终端 raw 恢复放 finally(崩溃也还原 ECHO)。
  3. 审计方法grep "\.lock\(\)",后一行不是 try { 的就是嫌疑;重点看"持锁期间调可抛异常方法"的临界区。

三种"进程活着但死了"的鉴别(高频排查场景):

特征定性根因
CPU 100% + 采样落业务协程忙等自旋计数空循环假 sleep(§3.4)
CPU 0% + 全线程 park + CJ_ProcessorStopWithLastCheck + abstime=0x0stop 路径某协程 park 在无超时受管原语(多为泄漏的锁,§3.2)
CPU 低 + 单线程阻塞在 futex普通锁等待持锁线程卡死/未释放

3.3 裸 socket 无超时:connect 挂死 = 请求永久卡死

现象(v1.3.6,首个 LLM 请求 442s 无响应):TUI 首个请求发出后状态行永久冻结,看门狗 8 次 interrupt 全部无效。与 §3.1 的关键区别:日志里没有 run 正常结束——run 线程自己还卡在第一个 LLM 请求里。

根因(三层叠加)

  1. 仓颉裸 TcpSocket.connect 没有超时参数(1.0.5 实测):DNS/TCP/TLS 任一阶段挂死时,readTimeout 不起作用(它只在"数据可读性"上生效,覆盖不到 connect 本身);write() 大 payload 经代理也可能挂死。
  2. abort() 打不断 connectabort() 只做 close(this.socket),而 socket 在 connect 成功返回后才赋值——connect 挂死期间 socket == Noneclose(None) 空转。中断信号传得到、关不到还没建好的连接。
  3. 请求阶段整体无可中断超时: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=1sconnect() ~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)。

排查路径(可复用)

  1. ps -o etime,time,%cpu -p PID:CPU 100% + 累计时间巨大 → 自旋而非阻塞
  2. ss -tnp:sshd Send-Q=0 → 排除 SSH 链路
  3. strace -p PID -f -cfutex + epoll_wait(timeout=0) 高频 → 忙轮询特征
  4. 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)。

根因

  • killSessionterminate(force:true)(SIGKILL)不 wait() → 每个超时/关闭的会话进程都留一个未回收僵尸
  • runTui 退出路径从不关闭 BashTool 会话、断开 MCP、Log.shutdown()——长生命周期后台(Log sink 协程、bash 管道读协程)退出后继续残留;主进程若先停摆,泄漏的 bash 会一直持着 stdout 管道(读协程阻塞在 read、bash 收不到 EOF)。

解法

  • terminate 后必 wait(timeout:5s) 回收僵尸(加 testReapedCount() 观测)。
  • runTuifinally 退出清理:BashTool.close() + mcpManager.disconnectAll() + Log.shutdown()各独立 try/catch(一个清理失败不挡住其余)。
  • 别依赖"进程退出自动收子进程"——主进程若先停摆,"自动收"永远不会发生。

四、标准库 API:文档没写、实现和预期不一样的坑群

4.1 Directory.walk:非递归 + 回调返回 false 终止整个遍历

现象(曾让 grep/glob 的目录搜索"只搜第一层"且"遇 .git 整个搜索停止"):直觉以为 Directory.walk 递归遍历子树。

实测(1.0.5):

  • 非递归:只遍历直接子项,不进子目录。
  • 回调返回 false 终止整个遍历(不是"跳过当前项")——想忽略 .gitreturn 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 -rfcreate,避免用例间残留。

4.5 时间:toUnixTimeStamp(大小写坑)+ 会话 ID 碰撞

  • DateTime 的时间戳方法是 toUnixTimeStamp(): Duration——Stamp 的 S 大写(写成 toUnixTimestamp 找不到方法)。
  • 秒.毫秒 精度的 ID 会碰撞savesaveFork 同毫秒调用生成相同会话 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.cjpackage 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 修复必配)

  1. 先写复现测试(红)再修复(绿)——测试用事故真实数据钉死(如"byte60=0x8B 的中文串"),前提自证(旧逻辑下断言失败)+ 修复后全绿。
  2. 并发/退出路径类 bug 用观测器断言(testReapedCount()getCompactionMode()),不测私有实现。
  3. 工具体走 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.tomldependencies 段。独立仓库引用已发布的库用 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 成功与否只看 errorgrep 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,%cpustrace -f -cfutex+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),事后进程存活、栈正常——活进程抓不到死点。定位靠:

  1. 日志窗口二分:最后一条日志 vs 预期应出现的日志,缩小死亡区间。
  2. 文件 mtime 锚点:死亡区间内"应写入而未写入"的文件(如 .4 会话文件 mtime 有、记忆文件 mtime 无 → 死在两者之间)。
  3. 最小复现:怀疑点用最小脚本复现(如 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 加参数后全仓搜旧模式补齐,漏一处即编译失败。

七、全量坑速查清单

#危险度
1s[i]/字符串迭代给 Byte(size 按字节)§1.1
2String[0..N] 按字节切片 + 校验 UTF-8 边界(切中文抛异常)§1.2
3StringBuilder.append(Byte) 按十进制整数输出§1.3
4String(Rune(byte)) 逐字节重建 → 摩尔化§1.4
5字节流拼串必须字符边界 + safeFromUtf8(3 条路径各一次)§1.5
6lambda:零参 { => };捕获 var 须直接调用(let 快照/let 引用对象绕行)§2.2
7无默认参数;普通参数无命名(p!: 才有)§2.1
8if (let x <- opt) 绑定仍 Option;复合 if-let 用 &&?? 优先级低§2.3
9Byte=UInt80x41u8);JsonValue 形态用 match kind();enum 模式参数数量全仓同步§2.4
10仓颉默认溢出检查(哈希/位运算 UInt64 + 截断)§2.4
11FFI:名字匹配 C 符号;static foreign 须顶层;CPointer<UInt8>§2.5
12块注释内禁 /* 序列§2.6
13spawn 未捕获异常只杀任务线程(收尾必 try/catch;实参先 let 接住)§3.1极高
14Mutex.lock() 临界区抛异常 = 锁泄漏 = stop 路径全停摆(try/finally 守护)§3.2极高
15TcpSocket.connect 无超时(readTimeout 管不到;预算竞速原语下沉传输层)§3.3
16计数空循环假 sleep 饿死主 worker(用 runtime sleep§3.4
17terminate 后必 wait;退出路径显式关 Log/MCP/会话(独立 try/catch)§3.5
18Directory.walk 非递归 + 回调 false 终止遍历(显式递归)§4.1
19集合无 map/filter;StringBuilder.append 返回 Unit 不可链式§4.2/4.3
20OpenMode.Append 自动建文件;Directory.create 不幂等(测试独立目录)§4.4
21toUnixTimeStamp(大写 S);会话 ID 秒.毫秒碰撞(加计数器)§4.5
22split 尾空串需 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
26cjpm add 不存在(手写 toml);独立包测试需 cd libs/<lib>§5.3/5.4

三条总纲

  1. 凡是"按字符"的直觉操作(取/切/拼/数),先确认你在操作的是字节(仓颉 String 底层是 UTF-8 字节)。
  2. 凡是 spawn / 持锁 / 裸 socket / 进程退出,先假设它会失败(未捕获异常、临界区异常、connect 挂死、僵尸残留),兜底再干活。
  3. 修一处必 grep 全仓同类(同一模式 bug 总会在 N 条独立路径各出现一次)。

完。本文基于 cjh 项目 28 轮开发、300+ 单测的实战踩坑整理;每条都可回查 docs/开发文档与踩坑记录.md 对应章节的完整事故上下文。

Logo

一站式 AI 云服务平台

更多推荐