一个 WebKit Bug 导致小程序页面跳转失败的问题的排查经历

650次阅读  |  发布于2年以前

前言

为了让读者对问题理解更清晰,先介绍下什么是小程序,以及什么是小程序容器:

小程序本质上就是 H5 应用,加上 4 个增强能力:

  1. 拦截 WebView 的网络请求,重定向到本地已下载好的 JS、CSS 等资源的离线包能力
  2. 通过内部的 serviceWorker,实现渲染和业务逻辑并发的能力
  3. 通过 js bridge,那客户端能力复用给 H5 的 JSAPI 能力
  4. 通过复用客户端的 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。如下图:

image.png

但在我对小程序 uglify 后的代码的 log 打印测试中,以上代码打印0。为了缩小问题场景,经过我的反复测试,最终得出最简单的复现场景:用 macOS 的 Safari,打开元素检查器,在 console 的 tab 中,在 Disable Breakpoints 的情况下,以上代码会打印0。如下图:

image.png

但若Enable Breakpoints之后,则打印1。如下图:

image.png

也正因为这个行为,导致了本文中遇到的问题。

  1. 正因为BreakpointsEnable与否,会改变程序行为,因此连接 Debugger 后,Breakpoints 默认 Enable,导致跳转失败问题无法复现。我尝试连接 Debugger 并将 Breakpoints 给关闭后,问题也可以复现
  2. 未被 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 在处理这个表达式是空操作的。因为localRegisterID* 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 这一编译选项。

这个问题的排查,是一个比较少见的排查难度较高、问题根因较深的问题,排查期间的思考记录下来以为日后参考。

人的固定认知,不一定就是对的。比如,我认为 Debugger 不会改变程序行为,这在 Objective-C 和 Swift 的世界或许是对的,因为端上执行的是被编译好的二进制,Debugger 的不会改变原有逻辑;但在 JS 的世界,即使是 AOT,每次刷新也会重新编译,使环境影响代码行为成为可能。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8