War story: the hardest bug I ever debugged

突然之间,没有任何明显原因,Google Docs 充斥着各种错误。 我是如何花费2天时间和一位同事才解决了我debug过的最难的bug的。

Jacob Voytko Mar 24, 2025

当我在 Google Docs 团队时,我们每周都会进行 bug 分类,寻找新的问题并随机分配给团队成员进行调查。 有一周,我们出现了一个新的、遥遥领先的最高错误。

这是一个致命错误。 这意味着它会阻止用户在不重新加载的情况下进行编辑。 它与 Google Docs 的任何版本发布无关。 堆栈跟踪提供的信息非常少。 没有出现用户投诉激增的情况,所以我们甚至不确定它是否真的发生了——但如果 真的 发生了,那将非常糟糕。 它仅在 Chrome 的特定版本中出现。 这并没有听起来那么有用,因为我们经常编写特定于浏览器的 Docs bug,这些 bug 只会影响 Internet Explorer、Firefox、Safari 和 Chrome 中的一个。

我尝试在开发环境中重现。 这很重要,原因有二:

好的,我该从何开始呢? 我在日志中搜索了遇到此问题的内部用户。 我希望有人能告诉我,“哦,是的,每次我尝试执行 $foo 时,它都会崩溃。” 但是没有内部用户受到影响。 回到起点。

我尝试了一段时间的疯狂编辑。 我添加了尽可能多的深奥功能,从新闻网站复制/粘贴了一堆内容到 Docs 中,试图触发该问题,还玩了一段时间的表格。 没用。

接下来该做什么? 当时,Docs 有一个基本的脚本工具,可以执行重复操作。 它主要用于性能基准测试,但因为它提供了Consistent Behavior,所以我尝试了一下。 我创建了一个包含 lorem ipsum 的 50 页文档,并让脚本将整个文档加粗和取消加粗 100 次。 大约在第 20 个周期左右,它崩溃了。 我检查了我的控制台,发现是那个问题错误!

我又做了几次。 不总是第 20 次迭代,但通常发生在第 10 到 40 次迭代之间。 有时它永远不会发生。 好的,这个 bug 是非确定性的。 我们的开局不利。

我考虑一下重现步骤。 加粗和取消加粗大量文本有什么有趣的地方吗? 实际上是有的。 在许多字体和许多文本样本中,加粗文本比未加粗文本更宽。 这对于我使用的字体来说是真的。 所以这可能与包装大量文本行有关。

我设置了一个断点并开始调查。 崩溃看起来是由视图中的一些错误簿记引起的,因为实际崩溃是读取了垃圾缓存值并试图对其进行操作,从而导致崩溃。

当时1,Google Docs 不像你可能期望的那样生成 HTML 页面。 它绝对定位了屏幕上菜单下方的每一件事。 为了支持这一点,Docs 有一个完整的布局引擎,在每次按键时运行。 为了在 2010 年代在浏览器中以某种方式提高性能,视图中的所有内容都被缓存到极致。

这意味着什么? 崩溃的地方是错误的下游。 错误发生了。 然后对错误进行了一些操作。 然后一些累加器接受了错误的值。 然后它们被写入缓存。 最终,过了一段时间后,出现了足够的簿记错误,导致了崩溃。

如果你把所有东西都放在一起考虑,这是一个最坏的情况。 这个 bug 是非确定性的。 是什么原因导致的呢? 不确定性积累的亚像素渲染错误? 杀了我吧。 其次,重现速度很慢。 加载开发版本的编辑器可能需要 20 秒,触发问题需要 40 秒。 然后我需要检查状态,直到我发现一些错误,然后弄清楚如何在错误的东西被缓存或添加到队列的那一刻设置断点。

如果你没有调试过很多神秘问题,这不是你想要的。 通常,你希望通过二分搜索来调试一个神秘问题。

然后你不断地减半可能导致问题的原因,直到它明显发生在某个组件中,然后 bug 的末日就到了。

还有一些不利于我的因素。 在此之前,我主要在服务器、模型和网络代码中工作。 我远非视图方面的专家,而视图是当时应用程序中最复杂的部分。 所以我叫来了一位实现了许多视图功能的同事。 已经 12 年了,所以我不太记得了,但我们的对话大致如下:

我:“我正在调查突然成为我们最高 bug 的那个视图崩溃。”他:“你对视图有什么具体问题吗? 我现在有很多事情要做。”我::给他看了重现步骤以及我目前发现的内容::他:“我会清空我的日程。”

我们就坐在那里,慢慢地将我们的断点一次又一次地向后移动,整整两天,越来越接近原因。

大约一天半后,我们有了一个突破:罪魁祸首是在一段特定的簿记代码2中。 它在代码的一部分中,该部分更新了累加器值。 所以像我们做过几十次的那样,我们更新了我们的断点,以便更早地触发。 我们重新加载文档并执行重现步骤。 最终,断点被触发。 我们盯着函数中变量的值。 它一定 现在 正在发生。 有些不对劲。

数学不对劲。 我的同事添加了一些日志语句,我们重新加载并再次运行重现用例。 这仍然没有意义。

我指着函数中间的 Math.abs() 调用。 “我们可以记录这个 Math.abs() 调用的输出值吗?” 我们争论这是否值得花费时间,但他承认,如果它以某种方式返回负值,实际上可以解释这个数学问题。

我们重新运行重现。 我们查看记录的值。 Math.abs() 对于负输入返回负值。 我们重新加载并再次运行它。 Math.abs() 对于负输入返回负值。 我们重新加载并再次运行它。 Math.abs() 对于负输入返回负值。

我们开始讨论为什么会这样。 我们检查了该函数是否已被覆盖。 该函数仍然是内置函数。 我们盯着这个函数的每一个字符。 一切看起来都很好。

然后我们叫来了我们的技术负责人/经理,她以成为人类 JavaScript 编译器而闻名。 我们解释了我们是如何走到这一步的,Math.abs() 返回负值,以及她是否能找到我们做错的任何事情。 在说服了她我们没有以某种方式可怕地犯错之后,她坐下来查看了代码。 她的 CPU 使用率飙升至 100%,她用俄语嘟囔着关于解析树或其他的东西,同时盯着代码并在调试控制台中输入内容。 最后,她向后靠去,并宣布 Math.abs() 对于负输入肯定会返回负值。

现在,在 Google 工作的主要优势之一:秘密渠道! 我联系了一位 Chrome 联系人,询问我应该如何弄清楚该问谁。 他们给了我一些令人讨厌的“技术上这是一个 V8 问题,而不是 Chrome 问题”。 我跳过了链中的一个链接,并提交了一个 V8 bug 或询问了 V8 团队中的某个人。 我真的不记得是哪个了。 V8 团队立即指向了他们的 bug 跟踪器中已经处于 Fixed 状态的一个 bug。

所以发生了什么? 显然,V8 最近重构了他们的优化过程。 在我记忆中,这是问题所在: V8 有两个级别的优化。

  1. 大多数代码使用的基本级别。 编译过程非常快,但优化程度不高。
  2. 热路径的超级优化级别。 要了解什么构成热路径,请创建一个 50 页的 Google Doc,并加粗和取消加粗 20 次,并想象一下每个单词多次运行的函数需要运行多少次。

在进行重构时,他们需要为每个操作码提供新的实现。 有人不小心将超级优化级别的 Math.abs() 变成了恒等函数。

但没有人注意到,因为它几乎从不运行——而且当它运行时,有一半的时间是正确的。

确信我们已经找到了问题的根源,我们为特定的 Chrome 版本添加了一个临时的浏览器检查。 如果它是该版本的 Chrome,它只会内联执行手动 if 语句来执行该操作。 我们还添加了一个非常长的注释——带有引用——解释说由于 V8 版本存在回归,math.Abs() 可能会在此特定 Chrome 版本中返回负值,并且请在 我们对该 Chrome 版本的使用率降至足够低时将其删除。

就这样:花费 2 天时间来找到一个已经修复的问题,并且无需任何交互即可解决。 作为通讯作者,我还能做些什么呢? 通常我喜欢找到一个可供学习的教训。 但这是 2 天令人疲惫的调试,而且不知何故没有任何可供学习的教训。

好吧,这就是生活。 这不就是最终的教训吗?