Node VM裡的錯誤捕獲如何保持上下文

2021-02-20 騰訊AlloyTeam
背景

當我們需要做上下文隔離來運行未知的異常複雜的異步代碼時,異常捕獲是必須要考慮的因素。假設有以下的場景:

vm 所執行的代碼可能來自於第三方,但是整個項目是提供基礎鏡像,第三方基於鏡像自行部署的,因此不關心 vm 裡的代碼安全問題,不用用到 vm2。

vm 裡的代碼是有可能出錯的,錯誤可能來自於同步代碼、異步代碼或者未處理的 Promise 錯誤。

vm 代碼是異步並行的,假設每次執行 vm 代碼都有一個 id。

vm 裡的代碼即使出錯,也必須要知道是哪個 id 的 vm 代碼執行出錯了,來執行兜底的策略。

我們先來嘗試使用不同的方式來捕獲錯誤。

1. 使用 process 捕獲

在 node 裡,如果要捕獲未知的錯誤,我們當然可以用 process 來捕獲:

process.on('unhandledRejection', (err) => {    });
process.on('uncaughtException', (err) => { });

這代碼不僅可以捕獲同步、異步錯誤,也能捕獲 Promise 錯誤。但同時,我們從 err對象上也獲取不了出錯時候的上下文信息。像背景裡的要求,就不知道是哪個 id 的 vm 出錯了。

2. 使用 try...catch

如果以 vm 來執行代碼的話,我們大可以在代碼的外部包裹 try...catch來捕獲異常。看下面的例子, try...catch捕獲到了錯誤,錯誤就沒再冒泡到 process 。

const vm = require('vm');
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', err);});
const script = new vm.Script(` try { throw new Error('from vm') } catch (err) { console.log(err) }`);
script.runInNewContext({ Error, console });

考慮異步錯誤1. vm.Script 異步函數

改寫上面的例子,將錯誤在異步函數裡拋出, try...catch捕獲不到錯誤,錯誤冒泡到 process ,被 uncaughtException事件捕獲到:

const vm = require('vm');
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', err);});
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection]:', err);});
const script = new vm.Script(` try { setTimeout(() => { throw new Error('from vm') }) } catch (err) { console.log(err) }`);
script.runInNewContext({ Error, console, setTimeout });

那有什麼辦法捕獲異步錯誤嗎?辦法還是有的,node 裡有個 domain 模塊,可以用來捕獲異步錯誤。(雖然已經標記為廢棄狀態,但是已經用 async_hooks 重寫了,意味著即使真的被廢棄,也能自己實現一個)

繼續改寫上面的例子,將 vm 放在 domain 裡執行,可以看到錯誤被 domain 捕獲到了:

const vm = require('vm');const domain = require('domain');
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', err);});
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection]:', err);});
const script = new vm.Script(` try { setTimeout(() => { throw new Error('from vm') }) } catch (err) { console.log(err) }`);
const d = domain.create();
d.on('error', (err) => { console.log('[domain-error]:', err);});
d.run(() => { script.runInNewContext({ Error, console, setTimeout });});

2. Promise 錯誤

但是假如將上一個例子的 vm 代碼改成 Promise 執行呢?domain 捕獲不到錯誤,錯誤冒泡到 process 上:

const vm = require('vm');const domain = require('domain');
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', err);});
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection]:', err);});
const script = new vm.Script(` Promise.resolve().then(() => { throw new Error('notExistPromiseFunc') })`);
const d = domain.create();
d.on('error', (err) => { console.log('[domain-error]:', err);});
d.run(() => { script.runInNewContext({ Error, console, setTimeout });});

為什麼會出現這樣的情況?node 文檔裡是這麼說的

Domains will not interfere with the error handling mechanisms for promises. In other words, no 'error' event will be emitted for unhandled Promise rejections.

那有什麼辦法嗎?這裡想了兩個比較騷的寫法。

一、使用 filename

我們知道 vm 在執行的時候,是可以提供一個 filename屬性,在錯誤的時候,會被添加到錯誤堆棧內。默認值是 'evalmachine.<anonymous>' 也就是我們上面的錯誤經常看到的第二行代碼錯誤的位置。這就帶來了操作的空間。

const vm = require('vm');const markStart = '<vm-error>';const markEnd = '</vm-error>';
const getContext = () => vm.createContext({ console, process, setTimeout, });
const parseErrorStack = (err) => { const errorStr = err.stack;
const valueStart = errorStr.indexOf(markStart); const valueEnd = errorStr.lastIndexOf(markEnd);
if (valueStart !== -1 && valueEnd !== -1) { return errorStr.slice(valueStart + markStart.length, valueEnd); }
console.log('[parse-error]'); return null;};
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection]:', parseErrorStack(err));});
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', parseErrorStack(err));});
const getScript = (flag) => { const filename = `${markStart}${flag}${markEnd}`;
return new vm.Script( ` (() => { new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('${flag}')); }, 100) }) })() `, { filename } );};
(async () => { for (let i = 0; i < 3; i++) { await getScript(i).runInContext(getContext()); }})();
// >[unhandledRejection]: 0// >[unhandledRejection]: 1// >[unhandledRejection]: 2

看下上面的代碼結構,我們做了幾件事:

在 vm 代碼編譯的時候,以 vm-error 標識符標記了我們要傳遞到錯誤堆棧的值。

在 process 捕獲 Promise 錯誤。

在 process 捕獲到 Promise 錯誤的時候,從錯誤堆棧上根據標識符解析出我們要的值。

但是這樣的代碼存在什麼問題?

最主要的問題在於 filename是編譯進去的,即使生成 v8 代碼緩存的 Buffer,後面用這個 Buffer來編譯一個新的 script 實例,傳遞進新的 filename,仍然改變不了之前的值。所以會帶來代碼每次都需要編譯的成本。

我們可以來實踐以下:

const vm = require('vm');require('v8').setFlagsFromString('--no-lazy');
const markStart = '<vm-error>';const markEnd = '</vm-error>';
const getContext = myVar => vm.createContext({ console, process, setTimeout, myVar,});
const parseErrorStack = (err) => { const errorStr = err.stack;
const valueStart = errorStr.indexOf(markStart); const valueEnd = errorStr.lastIndexOf(markEnd);
if (valueStart !== -1 && valueEnd !== -1) { return errorStr.slice(valueStart + markStart.length, valueEnd); }
console.log('[parse-error]'); return null;};
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection]:', parseErrorStack(err));});
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', parseErrorStack(err));});
const getFileName = flag => `${markStart}${flag}${markEnd}`;
const code = `(() => { new Promise((resolve, reject) => { setTimeout(() => { reject(new Error(myVar)); }, 100) })})()`;
const scriptCache = new vm.Script(code, { filename: getFileName(-1),});
const scriptCachedData = scriptCache.createCachedData();
const getScript = flag => new vm.Script(' '.repeat(code.length), { filename: getFileName(flag), cachedData: scriptCachedData,});
(async () => { for (let i = 0; i < 3; i++) { await getScript(i).runInContext(getContext(i)); }})();

看上面的代碼,對比上一個例子,主要有這幾個改動:

緩存了 vm 代碼編譯後的實例, filename設置的 -1。

循環內的 flag 標誌是通過 myVar注入到 vm 的全局變量,在 vm 裡 throw這個 flag 錯誤值的。

循環內的 vm 執行, filename設置的 0 - 3。

結果:編譯後的代碼實例並不會因為使用 cachedData重新編譯後, filename就會被改變,因此就無法使用 cacheData+filename的方式來既要減少編譯時間又想要自定義錯誤堆棧。

二、重寫 Promise

當我們想同步和異步代碼都能捕獲得到,那麼只剩下 Promise 錯誤了。什麼情況會報 Promise 未處理的錯誤呢?也就是沒有寫 catch的情況。那麼如果我們改寫 Promise ,將每個 Promise 都加上一個默認的 catch函數,是否能達到期望呢?

const vm = require('vm');
let processFlag;
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection-processFlag]:', processFlag);});
const getVMPromise = (flag) => { const vmPromise = function (...args) { const p = new Promise(...args);
p.then( () => {}, (err) => { processFlag = flag; throw err; } );
return p; }; ['then', 'catch', 'finally', 'all', 'race', 'allSettled', 'any', 'resolve', 'reject', 'try'].map((key) => { if (Promise[key]) { vmPromise[key] = Promise[key]; } });
return vmPromise;};
const getContext = (flag) => vm.createContext({ Promise: getVMPromise(flag), console, setTimeout, });
const getScript = (flag) => { return new vm.Script(` new Promise((resolve, reject) => { setTimeout(() => { console.log("[vm-current-task]:", "${flag}"); reject() }, (1 + Math.random() * 4) * 1000); }) `);};
for (let i = 0; i < 3; i++) { getScript(i).runInContext(getContext(i));}

考察以上的代碼,我們做了這些事:

改寫了 Promise,在 Promise 添加了第一個 then方法來處理錯誤。

在自定義的 Promise 的第一個 then方法裡存儲了當前異步任務的上下文。

將自定義的 Promise 當做全局變量傳遞給 vm。

結果:在一個隨機的任務 ID 上,成功在 process 上捕獲到了上下文的信息。(但是 Promise 實現的精華在於 then之後的鏈式調用,這在上面的代碼是沒有體現的。)

方案可行嗎?看起來是可行的,working on...

關於AlloyTeam

AlloyTeam 是國內影響力最大的前端團隊之一,核心成員來自前 WebQQ 前端團隊。AlloyTeam負責過WebQQ、QQ群、興趣部落、騰訊文檔等大型Web項目,積累了許多豐富寶貴的Web開發經驗。這裡技術氛圍好,領導nice、錢景好,無論你是身經百戰的資深工程師,還是即將從學校步入社會的新人,只要你熱愛挑戰,希望前端技術和我們飛速提高,這裡將是最適合你的地方。加入我們,請將簡歷發送至 alloyteam@qq.com,或直接在公眾號留言~

相關焦點

  • nodejs中錯誤捕獲的一些最佳實踐
    先拋出幾個問題:應該用哪種方式暴露錯誤?throw、callback(err, result)、Event Emitter或者其他方式?如何假設函數的參數?是否應該檢測類型正確?非null,IP,QQ號碼?函數參數不符合預期該怎麼處理?應該如何區分不同類型的錯誤?例如Bad Request、 Service Unavailable應該如何提供有用的錯誤信息?應該如何捕獲錯誤?
  • 記一次 Node.js 應用內存暴漲分析
    經過排查,找出了部分原因:使用的 html-minifier 模塊有問題,如果輸入的內容是一個有錯誤的 HTML 結構,會使解析進入死循環,導致 CPU 佔用率 100%。在使用 vm 模塊時,使用姿勢錯誤,導致內存佔用無法釋放,使內存佔用暴漲。第一個問題我們今天不予討論,主要來說一下第二個問題。
  • 一篇文章教你如何捕獲前端錯誤
    一般對頁面的監控包含頁面性能、頁面錯誤以及用戶行為路徑獲取上報等。而本文將重點關注其中的錯誤部分,主要介紹一下常見的錯誤類型以及如何對它們進行捕獲並上報。這些error事件不會向上冒泡到window,不過能被window.addEventListener在捕獲階段捕獲。但這裡需要注意,由於上面提到了addEventListener也能夠捕獲js錯誤,因此需要過濾避免重複上報,判斷為資源錯誤的時候才進行上報。
  • Node.js 診斷指南 第二彈
    使用信號來觸發進程的段錯誤並且輸出 core dump。node.js 啟動 flag。在啟動 Node.js 應用的時候指定 --abort-on-uncaught-exception 來開啟程序觸發未捕獲的異常時自動 core dump 操作。gcore <pid>。
  • Node.js:10個最有用和有趣的新功能
    添加 --debug-brk 命令行捕獲EventEmitter上的偵聽器的名稱捕獲EventEmitter上的偵聽器的名稱參數,就會在你的應用程式的第一行打斷點,這樣你就能夠使用調試器啦。你可以使用 Chrome 的 DevTool 來調試你的 Node 應用程式,就和你調試前端 JavaScript 一樣,包括實時代碼編輯和完全異步堆棧調用功能等。
  • 不用try catch,如何機智的捕獲錯誤
    起源我們知道,React中有個特性Error Boundary,幫助我們在組件發生錯誤時顯示「錯誤狀態」的UI。為了實現這個特性,就一定需要捕獲到錯誤。開啟該功能,使代碼在捕獲的錯誤發生的位置暫停。如何解決對用戶來說,我寫在componentDidMount中的代碼明明未捕獲錯誤,可是錯誤發生時Pause on exceptions卻失效了,確實有些讓人困惑。
  • nodejs中的異常錯誤處理
    異步代碼的錯誤處理1. try/catch 接口異步代碼下使用try{}catch結構捕獲處理效果如何呢?所以當捕獲到異常時,顯式的手動殺掉進程,並開始重啟node進程,即保證釋放內存,又保證了保證服務後續正常可用。
  • python異常處理與上下文管理器
    錯誤可以通過IDE或者解釋器給出提示的錯誤opentxt('a.jpg','r')語法層面沒有問題,但是自己代碼的邏輯有問題if age>18: print('未成年')異常多指在程序執行過程中,出現的未知錯誤,語法和邏輯本身是正確的。
  • 一個由 Node.js vm 引發的 OOM 血案
    而 Compilation Cache 的 GC 機制,就是 CollectAllGarbage() 不會回收它(就是我們看到從 Trace GC 中看到的 testing GC in old space requested),只有 CollectAllAvailableGarbage() 才會將其回收。
  • vm2.js沙箱逃逸
    vm2沙箱github↓(https://github.com/patriksimek/vm2/)簡化沙箱模型"use strict";const {VM} = require('vm2');const untrusted = '逃逸代碼';try{    console.log(new VM().run(untrusted
  • 使用vue+node搭建前端異常監控系統
    根據sourcemap和錯誤日誌內容進行錯誤分析(一)異常收集原理首先先看看如何捕獲異常。其實如果你不打開控制臺都看不到發生了錯誤。好像是錯誤是在靜默中發生的。下面我們來看看這樣的錯誤該如何收集。try-catchJS作為一門高級語言我們首先想到的使用try-catch來收集。
  • JavaScript錯誤處理實用指南
    如果你熟悉它們後,你會感覺寫起來很爽。測試一般使用 it('description') 開始,然後在 should 中使用 pass/fail 結束。好消息是測試用例可以在node端運行而不需要瀏覽器。我建議多關注這些測試,因為它們能幫助我們提升代碼的質量。正如所顯示的, error() 定義了一個空的對象,然後嘗試訪問一個方法,因為 bar() 方法在對象中不存在而會拋出一個異常。
  • 如何使用 Node.js 和 Docker 構建高質量的微服務
    如果你在 Express 中傳遞了一個錯誤給next(),而沒有自己定義的錯誤處理函數處理這個錯誤,這個錯誤就會被 Express 默認的錯誤處理函數捕獲並處理,而且會把錯誤的堆棧信息返回到客戶端,這樣的錯誤處理是非常不友好的,還好我們可以通過設置NODE_ENV環境變量為production,這樣 Express 就會在生產環境模式下運行應用,生產環境模式下 Express 不會把錯誤的堆棧信息返回到客戶端
  • callback 和 promise 的錯誤捕獲-暗坑集錦
    (點擊上方公眾號,可快速關注)作者:大搜車前端團隊博客連結:http://f2e.souche.com/blog/callback-he-promise-de-cuo-wu-bu-huo-an-keng-ji-jin/最近忙於業務開發,好久沒有更新博客了,把最近開發中踩到的關於錯誤捕獲的坑
  • 在 Node.js 中使用 Async Hooks 處理 HTTP 請求上下文實現鏈路追蹤
    本節將會介紹如何基於 Async hooks 提供的 API 從零開始實現一個 AsyncLocalStorage 類(異步本地存儲)及在 HTTP 請求中關聯日誌的 traceId 實現鏈路追蹤,這也是 Async Hooks 的一個實際應用場景了。何為異步本地存儲?
  • Node-MySQL:如何在node.js裡連接和使用mysql
    在這種情況下,請輸入:$ npm install mysqljs/mysql引言這是node.js的mysql驅動。它是用JavaScript編寫的,不需要編譯,完全遵循MIT許可協議。至於如何取捨,就要看你怎麼去處理所遇到的錯誤了。不管哪種類型的錯誤,那都是致命的,我們需要去看所提示的具體的錯誤信息。連接參數在建立新連接時,可以設置以下參數:host:連接的資料庫地址。(默認:localhost)port:連接地址對應的埠。
  • Node.js應用實戰和原理解析
    上面我們介紹Node.js的環境搭建,模塊安裝、引用,以及如何創建和引用自己的模塊,並對每一個點都做了相應的示例。現在我們已經能夠讓項目run起來了,項目運行過程中如果出現異常怎麼辦,這是程序猿尤為關心的。
  • 一種Vue應用程式錯誤/異常處理機制
    語句處理運行時錯誤,通過適當的單頁或者集成測試減少邏輯錯誤,http 錯誤可以通過使用 Promise 來處理。全局配置vue 應用程式有一個全局配置 vue.config,可以配置禁止日誌和告警、devtools、錯誤處理程序等等。可以用自己的配置覆蓋這些配置,對於錯誤處理,可以為其分配一個處理函數 Vue.config.errorHandler。在整個應用程式中,任何 Vue實例(Vue組件)中的任何未捕獲異常都會調用該處理程序。
  • 為 Node.js 應用建立一個更安全的沙箱環境
    我們先看看通常都能如何在 JavaScript 程序中動態執行一段代碼?比如大名頂頂的 evaleval('1+2')上述代碼沒有問題順利執行了, eval 是全局對象的一個函數屬性,執行的代碼擁有著和應用中其它正常代碼一樣的的權限,它能訪問「執行上下文」中的局部變量,也能訪問所有「全局變量」,在這個場景下,它是一個非常危險的函數。