代码生成工具仅用正则检查闭合标签判断成功是常见陷阱,完整标签不代表代码能运行,真正验证需实际执行;揭露成功信号与可用性的时间差。
我们在开发 Zugo,它能把一句文字描述变成一个可以运行的游戏、网站或 app。有段时间,我们用下面这段代码判断构建是否成功:
function htmlComplete(text) {
return /<\/html>/i.test(text);
}
一个闭合标签。这就是判断构建成功与否的关卡。只要模型输出了 </html>,这一轮就算成功,界面会显示绿色对勾,同时扣除 credits。至于页面能不能真正打开,并不在判断范围之内,因为扣费发生时,我们还没有把文档挂载到任何地方。
如果你正在开发任何生成代码的产品,这就是一个很容易掉进去的陷阱。文件是否已经写出来,很容易观察;文件能否正常运行,却没那么容易判断。于是,在不知不觉中,“能否运行”就不再是“完成”的标准了。
我们的第二道检查会统计开始标签和结束标签的数量。它的代码注释已经坦白说明了自己的能力边界:这只是平衡检查,并不代表“页面能够渲染”。一个文档完全可以做到标签严格配对、以 </html> 结尾,然后在第一个 script 的第一行就抛出异常。这样的构建仍会被计费,并被判定为彻底成功。
更糟糕的是,失败会以一种最消耗用户信任的方式隐藏起来:流式输出一结束,对勾立刻变绿;真正的结果却要几秒后才会到达。根据我们在自身流程中的测量,这段延迟对于网站是 2.6 秒,对于游戏是 4.2 秒,对于 React app 是 6.2 秒;如果第一次判定失败,还会额外进行一次静默的重新挂载。因此,一个失败的构建可能会在空白页面上顶着绿色对勾长达约 12 秒。这个时间已经足够让用户觉得产品在欺骗自己,然后直接关闭标签页。
显而易见的修复方式,是用 error boundary 包裹 preview 并监听错误。但在我们的场景中,这行不通。如果你也在沙箱中运行生成的代码,背后的原因值得了解。
preview frame 使用的是 sandbox="allow-scripts",没有设置 allow-same-origin。这会让它拥有一个 opaque origin,而这正是我们想要的效果:生成的文档绝不能访问宿主 app、宿主的存储或 session。但同一堵隔离墙也意味着宿主无法看到 frame 内部发生了什么。包裹在 preview 外层的 React error boundary 捕获不到文档内部的任何错误。如果用户看到了我们的 error boundary,那说明崩溃的是编辑器,而不是他们构建出来的内容。
因此,监听器必须运行在 frame 内部,也就是说,我们必须把它注入进去。
我们会在生成的文档前面插入一小段 script,通过 postMessage 将 error、unhandledrejection 和 console.error 报告给宿主。这里有两个细节,决定了这套机制究竟有没有实际价值。
它必须放在 <head> 中,并且位于构建内容写入的所有代码之前。我们的第一个版本把 hook 添加在 </body> 之前。如果某段 script 在加载过程中抛出异常,它执行时监听器还不存在。结果,声音最大的那些故障,反而恰恰没有任何东西能够报告。现在,我们通过测试固定了执行顺序:测试会断言 hook 的位置索引小于 </head>,同时也小于构建内容放在 body 中的任何 script。
console.error 并不等同于失败。React 会通过 console.error 输出普通的开发警告,而 "each child in a list should have a unique key" 并不代表 app 已经损坏。因此,harness 会把它作为一个独立字段进行报告:
var ce = console.error;
console.error = function () {
rep({ logErr: [].map.call(arguments, s).join(" ").slice(0, 400) });
return ce.apply(console, arguments);
};
下游所有负责判定通过或失败的逻辑,在统计任何错误之前,都会先检查 if (d.logErr) return;。这类警告仍然会被记录,只是永远不会导致一个健康的构建被判定为失败。如果把警告和真正的错误合并到同一个通道中,最终得到的验证器就会不断虚报警情,直到再也没有人愿意认真看它的结果。
如果必须把其他规则全部丢掉,我唯一会保留的就是最后这条:判定结果只能朝一个方向变化。
if (tn.files !== vFiles || (tn.rendered && (!tn.rendered.ok || v.ok))) return tn;
一旦某个构建被标记为失败,之后任何信号都不能再把它改成成功。第二次渲染、重试、重新挂载,或者某个碰巧没有再次出现的错误,都不能把失败重新提升为成功。再配合挂载后的 9 秒观察窗口——在这段时间内发生的错误仍会被计入——就堵住了一个显而易见的逃生通道:有问题的构建因为不稳定,最终偶然报告了一次正常结果,于是系统便告诉用户一切都没有问题。
现在,绿色对勾也会等待。在“流式输出结束”和“frame 返回结果”之间,UI 会显示系统正在检查构建内容能否打开。这是一种中立状态,而不是对结果的断言。我们宁愿展示三秒钟的不确定,也不愿先给出一个确定的结论,随后又证明它是错的。
问题不在于这个正则表达式。对于它负责的事情来说,这个正则完全没问题。真正的问题是,我们让一个成本低廉、容易观测的指标,代替了真正重要但观测成本更高的指标——仅仅因为后者来得更晚,而前者现在就能得到。
下面三个问题,值得你拿来审视自己的 pipeline:
你的成功信号实际观察到的究竟是什么?不要看它叫什么名字,要看它真正检测了什么。
在真正的结果到达之前,是否已经发生了计费之类不可逆的操作?
一次失败,是否可能被随后出现的、更加安静的信号重新提升为成功?
如果你想看看加入 harness 之后的版本,可以免费试用 Zugo;其中的模板都是真实的构建结果,你可以打开并编辑。
对于后续操作,你可以考虑屏蔽此人和/或举报滥用行为。