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

紫雲飛發表於2016-06-26

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 無法重現了。

相關文章