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