一个 WebKit Bug 导致小程序页面跳转失败的问题的排查经历
本文作者
白烽,7 年 iOS 开发经验,现就职于蚂蚁金服
林作健,LLVM、V8 领域专家,现就职于阿里巴巴
前言
为了让读者对问题理解更清晰,先介绍下什么是小程序,以及什么是小程序容器:
小程序本质上就是 H5 应用,加上 4 个增强能力:
拦截 WebView 的网络请求,重定向到本地已下载好的 JS、CSS 等资源的离线包能力 通过内部的 serviceWorker,实现渲染和业务逻辑并发的能力 通过 js bridge,那客户端能力复用给 H5 的 JSAPI 能力 通过复用客户端的 UI 实现,实现仿原生的 UI 能力
小程序容器就是为支持以上 4 项增强,而部署在客户端的 Native 代码(iOS、Android)。
为了实现以上 4 项增强能力,需要 Native 层与 WebView 层紧密配合。小程序容器架构极简版如下:
本文描述的 Bug,就发生在 JS 核心框架层_render。
问题表现
这天,收到线上问题反馈,小程序调用my.redirectTo()
之后,再调用my.navigateTo()
无响应,无法跳转下一个页面。
my.redirectTo()
和my.navigateTo()
都是小程序的 JSAPI,用来调用小程序容器提供的页面路由能力。支付宝小程序开发文档:Link[1]
问题表现视频
用支付宝官方小程序 Demo 演示问题。开始时,可以通过点击基础视图
按钮进入下一个页面;视频最后,再次点击基础视图
按钮时,业务无法跳转。
第一反应
收到问题反馈,第一件要做的事就是要求 QA 补充更多信息,缩小排查范围。
惯例排除 iOS 系统版本、小程序版本、机型、网络等因素后,得到以下关键信息:仅在 iOS 出现,可稳定复现,是个存在多个容器版本的深山老 Bug。只要能稳定复现的问题,就都好解决,我隐隐松了一口气;但是,UI 类的问题的排查难度往往是最高的。果然,接下来我经历了一个痛苦的排查过程。
第二反应
缩小问题范围后,第二件事就是自己上手复现。只要跟着调用栈调试一番,我估计能快速解决问题。配置好测试环境,在真机上复现问题后,我开开心心地连上 Safari Debugger,结果得到了一个让人傻眼的现象:问题无法又复现了。在 Safari 调试期间,一切运行正常,页面 redirect 后 navigate 的表现符合预期。
反复确认后,我得到以下结论:
不连接 Safari Debugger 就好使,连接 Safari Debugger 就不好使。
我承认那一刻我慌了。固然,以上结论意味着我没法用 Safari 调试,意味着问题定位的难度徒增,我即将花费更多时间;但更重要的是,这一结论超出了我的认知范围。恐惧的来源即是未知。对一个精神状态正常的程序员来说,应当有以下基础认识:99.99%的软件行为,都应该且只应该被代码影响。连不连 Debugger,对程序行为应当没有影响。这个玄妙的问题背后,定当有一个或愚蠢或深刻的原因。
然而,计算机这一行另一个更稳固的真理是,二进制的世界一切皆有缘由。任何意外的、让人摸不着头脑的问题,背后都有一个的原因静静地等待挖掘。只是有时,代码调用链路太长以至于追踪范围太大,难以定位;或者,问题的产生是多个 bug 共同作用导致,导致业务表现诡谲,难以分析。每一个这样的问题都是珍贵有价值的,正因为它难以发现,才使得挖掘它的过程充满意义。
艰难调试
此时,需要回答的问题变成了两个:
一,哪段业务代码造成了业务逻辑的分叉;
二,为什么 Safari Debugger 会导致程序异常。因为解决业务问题更要紧,我只能放弃断点调试,通过打 log 进行调试。
小程序源码本身不可能有问题,问题应该出在小程序底层的 JS 运行时框架上;小程序在 WebView 执行的都是被 uglify 的代码,无可读性。为了方便增加 log,我又编译了一份未被 uglify 的小程序核心 JS 框架。但另一个现象让我彻底麻了:只有被 uglify 的代码才能复现问题,可读性更好的非 uglify 版本一切正常。
在我粗浅的认知中,uglify 应当是一个极其谨慎的过程。因为从词法分析、语法分析和语义分析,我相信 uglify 的作者不会蠢到改变代码的 AST,否则它也不可能被大范围使用了。
然而认知归认知,面对 uglify 后的代码出异常的情况,此时我隐隐感觉是更底层的东西不对。可能是 uglify 的实现有 bug,在某种情况下改变了程序的行为。但是,这种程序行为的改变,不应该被 Safari Debugger 影响。也就是,如果是 uglify 的锅,那么产出的错误代码,不论是否连接 Debugger,都应该是不好使的,不应该连上 Safari Debugger 后就又好使了。
对 uglify 的有罪推演走不通,另一个更大胆的猜测是,问题发生在比 uglify 和 Safari Debugger 还要低的那一层,也就是编译器。但我一个小小的 button 仔何德何能可以碰到这样罕见的问题呢?
回到问题。没有 Debugger,也没有可读的源码,为了定位发生问题的代码,我只能基于 uglify 后的 JavaScript 代码增加 log,一点一点地逼近问题。有一说一,对着 uglify 的代码用 log 调试真不是人干的事。UI 逻辑本就复杂不好理解,还要拿着满屏的鬼画符和奇怪的 JS 写法,对比着源码理解调用栈,调试成本及其高。但这都是体力活,靠堆时间就能搞定,这里按下不表。
一个难捉的 WebKit Bug
经过辛苦的排查,最终将问题代码定位到一个函数。也正是该函数的问题,导致小程序中页面堆栈管理的逻辑不符合预期,最终导致了本文开始时介绍的问题。
我将发生问题的代码抽象成以下代码。这一段简单的代码,居然揭示了一个诡异的 WebKit Bug。
(function (){
var myCar = { color: 0 }
var myCarCopy = myCar;
myCar = (myCar.color += 1);
console.log(myCarCopy.color);
})();
试分析以上代码,请问 console 会打印什么?
代码解读
以上代码的关键在于第 4 行。括号内,
myCar.color += 1
是一个 Addition Assignment 调用,myCar.color
原先为0
,现在应该为1
。接着,在等号左边,myCar
原先是一个Obejct
,然后被赋值括号右边的返回值。第 4 行结束后,myCar
的值从{ color: 1 }
变成了1
。此时,myCarCopy
作为指向原先myCar
对象的指针,其color
的值应当是1
。
用 Node 测试,以上代码会打印1
。如下图:
但在我对小程序 uglify 后的代码的 log 打印测试中,以上代码打印0
。为了缩小问题场景,经过我的反复测试,最终得出最简单的复现场景:用 macOS 的 Safari,打开元素检查器,在 console 的 tab 中,在 Disable Breakpoints 的情况下,以上代码会打印0
。如下图:
但若Enable Breakpoints
之后,则打印1
。如下图:
也正因为这个行为,导致了本文中遇到的问题。
正因为
Breakpoints
的Enable
与否,会改变程序行为,因此连接 Debugger 后,Breakpoints 默认 Enable,导致跳转失败问题无法复现。我尝试连接 Debugger 并将 Breakpoints 给关闭后,问题也可以复现未被 uglify 的源码,业务逻辑虽与以上代码相同,但因代码语句不如 uglify 的版本紧凑,不会触发问题发生。将 uglify 的代码简单重写为以下形式后,问题也可以得到解决。
(function (){
var myCar = { color: 0 }
var myCarCopy = myCar;
myCar.color += 1;
myCar = myCar.color;
console.log(myCarCopy.color);
})();
上报至 WebKit 官方
确定以上结论后,我将问题反馈给了 WebKit,链接:https://bugs.webkit.org/show_bug.cgi?id=246787[2]。目前该问题的状态是进入了 apple 内部的 radar 系统
现在,我的面前有两条路。一是继续研究 WebKit 源码,找到 WebKit 内部问题根因;二是就此停下脚步。抬头浩瀚的 WebKit 工程,再低头看看我手上还没写完的 button,我陷入了两难。此时,我想起在 coursera 的[Build a System](https://www.coursera.org/learn/build-a-computer "Build a System")
这堂课中学到一个观点:
计算机的世界其复杂,人类为了解决这一问题,将计算机分成了很多层。比如站在 Software Engineer 的角度,可以不用理解 Hardware 的一个原因是:我们要相信,在地球的某个角落,已经有另一帮聪明的人帮你把更低一层的问题搞定了。因此,我们只负责调用底层,不用关心其实现。以此,防止自己被淹没在无穷无尽的细节中。
因此,为了防止自己被不熟悉的 WebKit 源码呛死,我选择了后者,暂时放弃对 WebKit 问题的追寻。
幸运的是,在我将本文发到内部后,得到 V8、LLVM 领域的大神 @林作健[3] 的响应。大神快速地定位了该问题,不仅从 WebKit 源码的角度解释了发生问题的原因,还给出了修复方案。
WebKit 源码问题 & 修复(硬核警告 ⚠️)
代码特点分析
代码引发问题一个重要特点是 myCar 这个变量发生了第二次赋值。第一次是myCar = { color: 0}
。第二次是myCar = (myCar.color += 1);
。这个特点是 JSC 漏处理的情况。
JSC 字节码对问题节点生成字节码的过程
首先遇到的表达式节点是=
。左手边是myCar
,右手边是(myCar.color += 1)
。JSC 对此的处理是在AssignResolveNode::emitBytecode
RegisterID* right = generator.emitNode(local, m_right);
generator.emitProfileType(right, var, divotStart(), divotEnd());
result = generator.move(dst, right);
可见是先生成右手边的表达式。把结果移动到dst
。这里的dst
就是 myCar。事实上local
也是dst
。这个 move 在处理这个表达式是空操作的。因为local
是RegisterID* local = var.local()
。var
来自Variable var = generator.variable(m_ident);``m_ident
就是符号myCar
。总结是这个表达式 emit 了右手边然后把结果赋予左手边的变量myCar
(有点废话)。
当处理右手边的ReadModifyDotNode
就出问题了。
先拿到myCar.color += 1
的左手边:
RefPtr<RegisterID> base = generator.emitNodeForLeftHandSide(m_base, m_rightHasAssignments, m_right->isPure(generator));
拿到的base
是和要求的输出dst
是同一个位置。这就是出错的根源了。
处理了+=
过程:
RegisterID* updatedValue = emitReadModifyAssignment(generator, generator.finalDestination(dst, value.get()), value.get(), m_right, m_operator, OperandTypes(ResultType::unknownType(), m_right->resultDescriptor()));
把+=的结果放在了dst
。因为dst
是和base
同一个位置。所以base
被覆盖位+=
后的结果 1。
后面的回写:
RefPtr<RegisterID> ret = emitPutProperty(generator, base.get(), updatedValue, thisValue);
base
此刻已经是结果,一个整数,目前值是 1。相当于表达式1.color = 1
解决方法
对于 JSC 几个 ReadModifyNode 而言,需要添加识别base
是否和dst
同一个位置的代码:
if (base.get() == dst) {
RefPtr<RegisterID> tmp = generator.newTemporary();
base = generator.move(tmp.get(), base.get());
}
可以对比下生成正确的字节码和错误字节码
错误版:
[ 17] mov dst:loc6, src:loc8
[ 20] mov dst:loc7, src:loc6
[ 23] get_by_id dst:loc8, base:loc6, property:0
[ 28] add dst:loc6, lhs:loc8, rhs:Int32: 1(const1), profileIndex:0, operandTypes:OperandTypes(126, 3)
[ 34] put_by_id base:loc6, property:0, value:loc6, flags:
正确版:
[ 17] mov dst:loc6, src:loc8
[ 20] mov dst:loc7, src:loc6
[ 23] mov dst:loc8, src:loc6
[ 26] get_by_id dst:loc9, base:loc8, property:0
[ 31] add dst:loc6, lhs:loc9, rhs:Int32: 1(const1), profileIndex:0, operandTypes:OperandTypes(126, 3)
[ 37] put_by_id base:loc8, property:0, value:loc6, flags:
对于业务的同学,应该尽可能避免写出表达式
a = (a.xxx+=x)
。
业务如何修复
好消息是知道了问题发生的原因,并且在 @林作健[4] 大神的帮助下定位了 WebKit 的问题,此锅与我无关。但坏消息是,如何 workaround,帮助业务解决问题,成了下一个难题。
首先,该问题发生在小程序容器的核心 JS 框架中,业务无法绕过;其次,该问题属 WebKit 范畴,属于系统底层问题,Native 和 JS 框架层都无法绕过;唯一可行的,就是通过定制 uglify,在 uglify 的 pass 完成后,在小程序框架编译后,人为地增加订正过程。
然而,这种方案也不完美。虽然很有必要,但这一编译打包 pass 的增加,增加了团队的理解成本和维护负担,且不具备扩展性。因为将来此外源码的变更,可能使这一 fix 方案无效;并且,将来其它代码也有可能出同样的 uglify 结果,无法从根本上避免。
我心底认为,这里一定有更好的修复方案。此时,回头看看出问题的这一行代码:
myCar = (myCar.color += 1);
这里有两个解决方案,要么将这一句拆分,要么,就是不要服用 myCar 这个变量,重新使用一个。我的猜测是,uglify 这里为了性能,检查到 myCar 这个变量在函数返回之前没有再使用到,因此做了优化,复用了这一变量名。按照这个思路,我想 uglify 一定有某个参数,可以禁用这个 feature。因此我去 github 找到 uglify 的文档做了一番研究,并没有找到合适的参数;为了寻求帮助,我提了一个 issue 给 uglify 的维护者,以求获得帮助。
链接:https://github.com/mishoo/UglifyJS/issues/5727[5]
此时才发现 uglify 有 --webkit
这个参数,来绕过一个奇怪的 WebKit 问题。加上这个参数,uglify 之后的源码就变成了以下形式:
(function (){
var myCar = { color: 0 }
var myCarCopy = myCar;
var anotherCar = (myCar.color += 1);
console.log(myCarCopy.color);
})();
此时,myCar 这一变量不再被复用。
最终,我们的 fix 方案是,在小程序的核心 JS 框架编译时,对 uglify-js 的调用增加--webkit 这一编译选项。
完
关注我们
文章已加入原创计划,欢迎更多朋友一起参与进来~
我们是「老司机技术」,一个持续追求精品 iOS 内容的技术公众号。欢迎关注。
关注有礼,关注【老司机技术】公众号,回复「2022」,领取 WWDC21 及以前的内参
参考资料
Link: https://opendocs.alipay.com/mini/api/fh18ky
[2]https://bugs.webkit.org/show_bug.cgi?id=246787: https://bugs.webkit.org/show_bug.cgi?id=246787
[3]@林作健: https://www.yuque.com/linzuojian/
[4]@林作健: https://www.yuque.com/linzuojian/
[5]https://github.com/mishoo/UglifyJS/issues/5727: https://github.com/mishoo/UglifyJS/issues/5727