Steven
Steven1 分钟阅读

那个让我们的应用无法退出的"安全检查"

自动更新是我们发布过的最难的功能——四天里连发六个版本,只为阻止它把应用弄成砖头。最糟糕的 bug 恰恰是我们为了谨慎而亲手引入的:一个 500 毫秒的"安全"检查,把一次失败的更新变成了一个你根本退不出去的进程。

工程
Electron
可靠性
GeekBye 版本发布
那个让我们的应用无法退出的"安全检查"

每个桌面应用开发者都会恰好低估一次自动更新。它看起来像是个已经解决了的问题——一个库下载新版本然后重启你的应用。然后你把它发布出去,你才明白"重启你的应用"是一个程序所能被要求去做的最危险的事情之一,因为它恰好发生在你的应用正在拆解自己、容错余量最小的那一刻。

GeekBye 的自动更新花了四天六个版本——v1.5.14 到 v1.5.19——才稳定下来。这是那段时间里最糟糕的 bug 的故事,而这个 bug 是我们自己为了谨慎而造成的。

六个版本,以及真正重要的那一个

这条弧线开局平平无奇。v1.5.14 修了一个尴尬的、拼写错误级别的 bug:更新源指向了一个并不存在的 GitHub 仓库名,于是更新器一直在检查一个 404。v1.5.15 加了一个手动的 "Check for Updates" 按钮和一条真正的错误信息。然后 quitAndInstall 的 bug 开始了,版本发得飞快——因为当你的更新机制本身坏了的时候,你没法通过更新机制去交付它的修复。每一次迭代都是一场手动重装的赌博。

真正重要的那一个是 v1.5.18。它的全部内容是一个提交,标题至今仍让我皱眉:restore original quitAndInstall behavior to prevent unkillable app.

"谨慎行事"是如何把应用弄成砖头的

事情是这样的。当一个更新下载完成时,Electron 的 quitAndInstall 应该关闭应用并换上新版本。在更早的一个版本里,有人——很合理地——担心无条件退出是有风险的。万一安装出错了呢?只在一切看起来健康时才退出,不是更安全吗?

于是代码里长出了一个看起来很有道理的防护:

autoUpdater.quitAndInstall(false, true)
setTimeout(() => {
  if (this.updateDownloaded)
    app.quit() // only quit if the update is still "good"
  else console.log('Error detected — keeping app open')
}, 500)

逻辑是:触发安装,等半秒,只有在 updateDownloaded 标志位仍然为 true 时才强制执行最终的 app.quit()——否则就让应用保持打开,这样用户就不会被晾在一边。

陷阱就在一行之外,在错误处理器里。那个处理器把 this.updateDownloaded = false。所以想象一次失败的安装:error 事件触发并清除了标志位。但 quitAndInstall开始拆解——它关闭了窗口,移除了应用的退出监听器。然后那个 500 毫秒的定时器醒来,检查现在已经是 false 的标志位,判定"检测到错误,保持应用打开",于是跳过了 app.quit()

现在你就有了——具体来说是在 macOS 上——可能最糟糕的状态。macOS 不会仅仅因为一个应用的最后一个窗口关闭了就退出它——这就是每个 Mac 应用都依赖的 window-all-closed 行为。所以进程仍然活着,但它**没有窗口,没有菜单栏路径,退出监听器也被拔掉了。**没有任何东西可以点。Cmd-Q 没有可以对话的对象。唯一的出路是从 Activity Monitor 里 Force Quit。那个"安全"检查把一次失败的更新——一个可恢复的小麻烦——变成了一个你杀不掉的僵尸。

修复:拆解必须无条件执行

v1.5.18 里的更正近乎咄咄逼人地无聊,而这正是重点。它移除了那些小聪明:

  1. 移除可能造成干扰的 window-all-closedbefore-quit 监听器。
  2. 销毁每一个窗口——window.destroy(),而不是 window.close()。关闭可以被某个处理器否决;销毁不能。当你已经下定决心要关闭时,你不去客气地征求意见。
  3. 调用 quitAndInstall
  4. 无条件地调用 app.quit()

没有标志位,没有定时器,没有"以防万一先保持打开"。因为关于关闭路径的真相是:一个半途而废的关闭比两种结果中的任何一种都更糟。彻底退出没问题。彻底保持打开也没问题。你唯一绝不能到达的状态是已拆解但仍在运行——而这恰恰是一个有条件的退出会把你困在其中的状态。

同一周教会的另外两条经验

杀不掉的 bug 是头条,但那场六个版本的手忙脚乱还锤炼出了另外两个值得偷师的习惯。

按精确签名而不是宽泛关键词过滤你的崩溃遥测。 手忙脚乱的中途,我们发现我们的错误上报被配置成丢弃任何含有像 permissiontokenmicrophone 这类词的内容——一次意在削减噪音的尝试,却在悄悄地吞掉真实的崩溃,只因为它们恰好提到了这些词。我们把这些一刀切的过滤器扯了出来,换成了精确的拒绝字符串(macOS 在权限被拒绝时发出的那条具体信息)以及像 ERR_NETWORK_CHANGED 这样具体的瞬态网络码。降噪和藏 bug 是同一个旋钮朝相反方向拧;如果你凭感觉过滤,你就会把你本来需要看到的那个东西过滤掉。

每条自动路径都需要一个手动的逃生出口。 自动更新本质上是尽力而为的——网络会抽风,安装会失败。所以每一种失败模式都配了一个人工兜底:手动的 "Check for Updates" 按钮、指数退避重试、一个重新检查的定时器,以及——专门为用户的手动尝试也失败的情况保留的——一条大白话的"删除应用并从网站重新安装"信息。自动路径是一种便利;手动路径才是保障。

结论

  1. 围绕一个不可逆操作的防护比这个操作本身更危险。 那个有条件的退出想要阻止一次糟糕的更新退出应用,结果却创造出了一个比退出或不退出都更糟的状态。关闭和安装路径应该是无条件且幂等的——绝不要门控在一个可变标志位上,因为另一个处理器可能会在你脚下把它翻转掉。
  2. 在 macOS 上,"没有窗口"不等于"没有应用"。 任何拆解逻辑都必须考虑到这个平台——在这里一个无窗口的进程会继续运行。要测试失败路径,在真实的操作系统上测,而不只是测顺利路径。
  3. 你通过更新系统交付的那个功能,没法通过更新系统来测试。 正是这种不对称让自动更新配得上偏执的、无条件的、经过大量人工验证的代码。你只有在它已经能用之后,才有机会用简单的方式去修它。

这是那场可靠性工作最早的一章,它最终演变成了 GeekBye v2。想知道那条路通向何方,请看打造一个第二版真正需要什么(v2.0.0),以及在把软件打磨到极致的解剖学里的整条弧线。