V8 的 typeof null 返回 "undefined" 的 bug 是怎么回事

1997 年,IE 4.0 发布,带来的众多新特性中有一个对未来“影响深远”的 DOM API:document.all。在随后的 6 年里,IE 的市场占有率越来越高,直到 2003 年的 95%。

在这段时间里,产生了两种成千上万的页面。第一种:IE only 的页面,由于超高的市场占有率,开发人员觉得根本不需要考虑兼容性,于是直接使用 document.all,比如:

document.all("foo").style.visibility = "visible"

甚至很多网站直接在服务器端判断客户端的 UA,不是 IE 的直接返回一句话:“本站只支持 IE。。。

第二种页面:开发人员使用 document.all 来识别 IE,只对 IE 展现特殊的效果:

var isIE = !!document.all
if (isIE) {
  // 使用 IE 私有的 DOM API,私有 CSS 特性
}

那个年代的很多书也都在讲这种判断方法,直到现在,2016年,估计还有少数人这么写,国内出版的垃圾书估计也有可能从别处复制粘贴这样的代码。

由于第一种页面的大量存在,Opera 在 2002 年发布的 6.0 版本里实现了 document.all,这导致的结果就是,第一种 IE only 的页面有不少可以在 Opera 中正常浏览了,这是好消息,但坏消息是,第二种页面反而都报错了,!!document.all 是 true 了,Opera 被当成 IE 了,Opera 不可能支持 IE 所有的私有特性。当时有不少人给 Opera 反馈 bug,开发人员表示无法修复。

这段时间里,为了抢占市场占有率,Mozilla 的人也在持续讨论要不要实现 document.all,bugzilla 上有很多历史帖子可查。最终,在 2004 年,JavaScript 之父 Brendan Eich 在 Firefox 0.10 预览版实现了 document.all,但有了 Opera 的前车之鉴,Brendan 在实现 document.all 的时候玩了个小技巧,那就是你可以正常的使用 document.all,但你无法检测到它的存在:

> document.all + "" 
"[object HTMLAllCollection]"
> typeof document.all
"undefined"
> !!document.all
false

Brendan 取名为“undetected document.all”,但在当时很多人也发现了,document.all 并不是真的检测不到,比如:

> document.all === undefined
false
> "all" in document
true

当时 Mozilla 的人也回复了:“这不是 bug,因为做这个改动是被迫的,而且这个改动是违反 ECMAScirpt 规范的,改动越小越好,in 和 === 就不去管了,毕竟极少数的人用 === 和 in 判断 document.all 存在与否”。现如今所有的浏览器都是这样的实现,HTML 5 规范里也是这么规定的

那段时间 Safari 才刚刚起步,但也有收到来自用户的不支持 document.all 的 bug,2005 年底,Safari 学 Firefox,实现了 undetectable document.all

2008 年,Opera 在 9.50 Beta 2 版本将自己直接暴露了多年的 document.all 也改成了 undetectable 的,变更记录里是这么写的:“Opera now cloaks document.all”。 Opera 的工程师当年还专门写了一篇文章讲了 document.all 在 Opera 里的变迁,还说到 document.all 绝对值得被展览进“Web 技术博物馆”。

2008 年底,Chrome 1.0 发布,Chrome 是基于 Webkit 和 V8 的,V8 当然得配合 Webkit 里的 document.all 的实现。

很戏剧性的是,在 2013 年,连 IE 自己(IE 11)也隐藏掉了 document.all,也就是说,所有现代浏览器里 document.all 都是个假值了。

在 V8 里的实现是:一个对象都可以被标记成 undetectable 的,很多年来只有 document.all 带有这个标记,这是相关的代码片段和注释

// Tells whether the instance is undetectable.
// An undetectable object is a special class of JSObject: 'typeof' operator
// returns undefined, ToBoolean returns false. Otherwise it behaves like
// a normal JS object.  It is useful for implementing undetectable
// document.all in Firefox & Safari.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=248549.
inline void set_is_undetectable();
inline bool is_undetectable();

然后在 typeof 的实现里,如果 typeof 的参数是 undefined 或者是 undetectable 的,就返回 "undefined":

Handle<String> Object::TypeOf(Isolate* isolate, Handle<Object> object) {
  if (object->IsNumber()) return isolate->factory()->number_string();
  if (object->IsUndefined() || object->IsUndetectableObject()) {
    return isolate->factory()->undefined_string();
  }
  if (object->IsBoolean()) return isolate->factory()->boolean_string();
  if (object->IsString()) return isolate->factory()->string_string();
  if (object->IsSymbol()) return isolate->factory()->symbol_string();
  if (object->IsString()) return isolate->factory()->string_string();
#define SIMD128_TYPE(TYPE, Type, type, lane_count, lane_type) \
  if (object->Is##Type()) return isolate->factory()->type##_string();
  SIMD128_TYPES(SIMD128_TYPE)
#undef SIMD128_TYPE
  if (object->IsCallable()) return isolate->factory()->function_string();
  return isolate->factory()->object_string();
} 

今年 2 月份,V8 做了一个改动,就是除了 document.all,要把 null 和 undefined 两个值也标记成 undetectable 的。当时,开发人员清楚的知道这个改动会让 typeof null 返回 "undefined",所以专门改动了 typeof 的实现,并且添加了个对应的测试文件

assertFalse(typeof null == "undefined")

看上去很完美,但其实这个改动产生了个 bug,这个 bug 后来流到了 50 和 51 的稳定版里。

在 4 月份,有人发现了这个 bug,提炼一下重现代码就是:

for (let n = 0; n < 10000; n++) {
  console.log(typeof null == "undefined")
}

Chrome 里执行结果如下:

在 for 循环执行若干次后,typeof null 会从 "object" 变成 "undefined"。

我当时也关注了这个 bug,不到一周时间后就修复了。当时 master 分支是 Chrome 52,开发人员觉的 bug 影响不大(我的猜测),就没有合进当时的稳定版 Chrome 50 里。

其实这个 bug 产生的原因是:V8 还有个优化编译器(optimizing compiler),叫 crankshaft,当代码执行次数够多时,JavaScript 代码会被这个编译器重新编译执行。crankshaft 里有一个它单独使用的 typeof 实现,和普通编译器(full-codegen)用的不一样:

String* TypeOfString(HConstant* constant, Isolate* isolate) {
  Heap* heap = isolate->heap();
  if (constant->HasNumberValue()) return heap->number_string();
  if (constant->IsUndetectable()) return heap->undefined_string();
  if (constant->HasStringValue()) return heap->string_string();
  switch (constant->GetInstanceType()) {
    case ODDBALL_TYPE: {
      Unique<Object> unique = constant->GetUnique();
      if (unique.IsKnownGlobal(heap->true_value()) ||
          unique.IsKnownGlobal(heap->false_value())) {
        return heap->boolean_string();
      }
      if (unique.IsKnownGlobal(heap->null_value())) {
        return heap->object_string();
      }
      DCHECK(unique.IsKnownGlobal(heap->undefined_value()));
      return heap->undefined_string();
    }
    case SYMBOL_TYPE:
      return heap->symbol_string();
    case SIMD128_VALUE_TYPE: {
      Unique<Map> map = constant->ObjectMap();
#define SIMD128_TYPE(TYPE, Type, type, lane_count, lane_type) \
  if (map.IsKnownGlobal(heap->type##_map())) {                \
    return heap->type##_string();                             \
  }
      SIMD128_TYPES(SIMD128_TYPE)
#undef SIMD128_TYPE
      UNREACHABLE();
      return nullptr;
    }
    default:
      if (constant->IsCallable()) return heap->function_string();
      return heap->object_string();
  }
}

那次改动开发人员漏改了这个 typeof 实现,导致了上面的 bug,修复很简单,就是把标红的那句判断挪下面,同时修 bug 的人专门新增了个测试文件,里面 %OptimizeFunctionOnNextCall() 是个特殊函数,可以让某个函数直接被编译进 crankshaft 里执行。

6 月 8 号,那个 bug 里又有人反馈,说他们的系统因为这个 bug 无法运行了,问什么时候修复代码能合进稳定版里,当时没有相关人员回复。

6 月 20 号,有人在 reddit 上公开了这个 bug,被人多次转到 twitter 上,那个 bug 下面又有了更多人的回复。

Chrome 的开发人员意识到问题有点严重了,于是将先前的 commit cherry-pick 进了 Chrome 51 里,Node 也进行了同样的操作

在这之后,又有三个不知道这个 bug 已经修复了的人报了重复的 bug,issue 621887issue 622628issue 5146

PS,V8 未来会淘汰 Full-codegen/Crankshaft,使用新的 Ignition(解释器) + Turbofan(编译器) 架构,在 Chrome 50 或者 51 里打开 chrome://flags/#enable-ignition 选项,就会发现 bug 无法重现了。

posted @ 2016-06-26 20:29  紫云飞  阅读(2475)  评论(0编辑  收藏