text-to-cad并发构建保护机制:锁等待与竞态处理的工程设计细节
【免费下载链接】text-to-cadA library of agent skills for CAD, CAE and CAM项目地址: https://gitcode.com/GitHub_Trending/tex/text-to-cad
text-to-cad 是一个面向 CAD、CAE 和 CAM 的 AI Agent 技能库,它最大的特色之一,是如何在多进程同时构建同一个模型时,用一套锁等待 + 竞态处理机制保护构建过程不互相踩踏。这篇文章带你读懂它的并发保护设计:从内核级 flock 锁到超时降级,每一环都藏着工程取舍。
为什么 CAD 工作流里并发构建是"定时炸弹"?
用 Agent 生成 CAD 模型时,很容易出现这样的场景:
- 你在 CLI 里跑了一次
gen构建,AI 同时在 Viewer 里触发同一模型的构建 - 一次构建动辄几分钟(OCP 网格化期间 GIL 会被长时间占用),两个进程如果同时写入同一目录,读者就会看到一个"写到一半"的半成品包
text-to-cad 的解法在 cadgen/coordination 包里,生产端(构建进程)和读取端(Viewer 服务器)共享同一套协议,而不是各写一份。
三个协调文件:哨兵与状态记录长什么样
每个产物的输出目录旁,都有一组"隐藏兄弟文件",定义在 coordination/paths.py 中。以输出目录<folder>/__cadgen__/models/widget.step为例:
.<name>.step.generation.lock 写者哨兵(构建时持有) .<name>.step.generator.lock 生成器哨兵(导出等不写包的操作持有) .<name>.step.generation.progress.json 状态记录(进度 JSON)两个哨兵文件是刻意为之:构建要"重写整个包",而导出(STL/GLB 等)只占用生成器、写到别处。读者由此能区分**"模型正在被重写"(必须隐藏磁盘上的旧产物)和"生成器忙"**(旧产物仍可用)。
内核托管的 flock 锁:为什么放弃心跳状态文件?
coordination/lock.py 的模块头注释,几乎就是一份"事故复盘"。早期版本用{pid, status, startedAt}JSON 状态文件 + 1 秒心跳线程判断"构建是否存活",结果暴露了三个致命缺陷:
| 缺陷 | 后果 |
|---|---|
| 状态文件只被写入、从未被"获取" | 两个并发构建各自放行,先完成的一方删掉共享文件时另一个还在写——读者在半成品上看到"无构建中" |
用os.kill(pid, 0)+ 30 秒心跳窗口推断存活 | 网格化长时间持有 GIL,心跳线程饿死,健康构建被误判为已死 |
| 生产端从不互相等待,只有 Viewer 等待 | 并发写入直接竞争同一目录 |
新设计只有一条铁律:锁状态归内核所有——进程崩溃或被 kill 时,内核随文件描述符关闭自动释放锁。代码里明确写着"No liveness inference lives here, and none may be added":不做 pid 检查、不做心跳、不做时间窗推断。
还有一个精巧的细节:读侧探测用共享锁LOCK_SH、写侧用排他锁LOCK_EX。因为flock是按打开文件描述计冲突而非按进程,若两个读者都用LOCK_EX探测,会彼此误报"有构建在飞"——修复前 4 线程并发下实测约 6% 的误报率。
锁等待与超时:--lock-timeout的设计取舍
CLI 的并发策略在 _internal/cli_locking.py 中:
- 默认无限等待(
WAIT_FOREVER = 0.0):Agent 发起构建就是要产物,直接放弃只会留下"没构建、也没办法"的空手结果,排队等待才是正解 - Viewer 必须不阻塞:它作为常驻服务显式传入超时,超时后不抛错,而是返回
{"ok": true, "contended": true}——"没错,只是模型正在被别处构建"
等待期间还有一个体感设计:on_wait回调在等待超过 0.25 秒后首次播报、之后每 30 秒重复一次waiting for another run to finish building ...。没有它,一个排队中的构建在两个流上都不输出任何内容,和"进程挂死"完全无法区分(见 lock.py 的 _acquire)。
💡 使用层面:SKILL.md 里写得很直白——构建会等待同一模型的并发构建,并持续在 stderr 播报;加
--lock-timeout SECONDS可改为放弃等待,此时该目标的 outcome 是contended,而不是built(参见 skills/cad/SKILL.md)。
旧进程残留的进度条:runId 归属机制
崩溃的构建会留下一份"非终态"的状态记录在磁盘上。如果没有识别手段,Viewer 会把一具"尸体的进度"渲染成当前构建的位置——比如显示"Meshing components 31/50",而当前构建其实一个组件都没网格化。
解法在 coordination/record.py:拿到锁时先在哨兵里盖上本次运行的 runId(UUID),读者只渲染 runId 与当前持锁者一致的状态记录。归属不匹配、schema 版本不认识、记录已终态——一律显示"无进度",安全降级。
写入侧同样是原子的:临时文件名带 pid 防多进程互踩,再os.replace落盘,轮询读者永远不会读到半截 JSON。
优雅降级:锁不可用,构建也绝不能失败
这套机制有一条贯穿始终的底线策略——"a build must never fail because a lock was unavailable":
- 无
fcntl/msvcrt、目录不可写、NFS/SMB 等不支持咨询锁的文件系统,统统降级为"无协调",照常构建 - 状态记录写不进去?降级为"无进度上报",绝不因此失败
- 在 Windows 的
msvcrt区域锁上,空哨兵无法加锁(越过 EOF 加锁是错误),于是一个"open 后、写 runId 前崩溃"的空哨兵被报告为降级而非"被持有",避免永久卡死后续所有构建 - 文件重命名在 SMB 上的共享冲突(WinError 32)由 atomic_replace.py 做 350ms 内的有界重试,但权限类错误立即上报——重试只留给"服务器还没跟上"这一种情况
值得借鉴的三个设计思想
- 权威来源唯一:谁活着,只问内核;状态文件只是"装饰",永远不承担存活判断
- 等待是默认,放弃是显式选择:把"排队"作为生产端默认语义,把"超时放弃"留给必须不阻塞的调用方
- 失败模式先于功能设计:每个环节(探测、加锁、写记录、重命名)都先回答"如果这里不可用,构建是否还能成功"
📁 想深入源码,核心路径:
- 锁原语:coordination/lock.py
- 文件布局:coordination/paths.py
- 状态记录:coordination/record.py
- CLI 超时与等待播报:_internal/cli_locking.py
- 原子替换:_internal/atomic_replace.py
【免费下载链接】text-to-cadA library of agent skills for CAD, CAE and CAM项目地址: https://gitcode.com/GitHub_Trending/tex/text-to-cad
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考