寫一個 Babel 插件,檢測並解決 JS 代碼中的死循環
背景
之前做的一個需求,需要探測用戶 js 代碼是否存在死循環。若發現死循環,則提前拋錯,而不是繼續執行直至線程卡死。
業界也有挺多類似的需求,比如 CodeSandbox 沙盒的 Infinite Loop Protection,可以避免用戶在調試代碼時寫了死循環導致頁面標籤崩潰。
能否通過靜態分析的方式檢測出死循環?如果不能,我們又應該如何在不借用其他線程的情況下,解決死循環卡住問題?
下面就讓我們一起來分析下這些問題吧。
死循環 Case
什麼情況下會導致死循環?列舉了常見的幾種情況:
-
無限循環:循環條件始終爲正 ,且循環體中沒有中斷語句
-
無限遞歸調用
-
無限渲染:表現在 React 等視圖框架,渲染函數執行時又觸發了數據變動
-
...
無限循環
while (true) { // 循環條件也可能是一個很複雜、有外部入參的判斷語句,但始終爲正
// 死循環
if(1 !== 2) { // 中止條件永不觸發
return
}
}
這類場景,循環條件始終爲正,而在循環體中,要麼沒有中止條件,要麼中止條件永遠不觸發,進而導致線程卡死。
無限遞歸調用
(function recursive() {
recursive(); // 死循環
})();
對於這類情況,執行引擎在達到最大遞歸調用棧深度後,便會拋出 RangeError ,我們無需主動處理。
RangeError: Maximum call stack size exceeded
無限渲染
這裏以 React 框架爲例,在 render 函數中又觸發了數據的變更。這邊的用例比較直白,現實中的用例可能會非常隱蔽。
import React from "react";
export default class App extends React.Component {
constructor() {
super();
this.state = {
num: 1
};
}
render() {
this.setState((state) => ({ state: state + 1 }));
return <div>{this.state.num}</div>;
}
}
import React, { useState, useEffect } from "react";
export default function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // infinite loop
}, [count]);
return <div>hello</div>;
}
第二個用例,控制檯輸出了以下報錯,並且渲染卡死。
檢測死循環
能否通過靜態分析的方式,檢測出一段代碼存在死循環?
先考慮第一種 「無限循環」 場景,如果我們發現循環條件執行結果始終爲 true ,且循環體中沒有中止語句(throw/return/break),那麼這類用例必定是死循環。
while(true) {
// 死循環
}
然而這樣的代碼畢竟是少數,大部分用例是在不經意間寫出死循環的,比如
while (x > y && (x % 2 === 0 || y % 2 === 1)) {
// 死循環,複雜條件難以分析
}
判斷複雜、涉及外部輸出,需要運行時分析,故純靜態分析難以判斷
該問題在可計算性領域被稱爲停機問題,已被證明無法通過一個通用算法分析出一段代碼是否存在死循環
運行時判斷
既然靜態分析無法解決,那麼是否換個思路:給循環體加點判斷代碼,當循環次數過多或者循環執行過久的時候,就認爲是死循環,並拋出異常。
我們先以執行過久作爲死循環判斷條件 (後面會繼續優化) :
- 對於無限循環的場景,可以這麼處理:
while(true) {
// 死循環
}
// 調整爲
let _loopStart = Date.now()
while(true) {
if(Date.now() - _loopStart > MAX_TIMEOUT) {
throw new RangeError('Potential infinite loop: exceeded')
}
// 死循環
}
for 循環、do...while 循環同理轉換。
- 對於循環的場景,可以這麼處理:
import React from "react";
let _loopStart = Date.now()
export default class App extends React.Component {
constructor() {
super();
this.state = {
num: 1
};
}
render() {
if(Date.now() - _loopStart > MAX_TIMEOUT) {
console.warn('Potential infinite loop: exceeded')
return;
}
this.setState((state) => ({ state: state + 1 }));
return <div>{this.state.num}</div>;
}
}
現在,我們就擁有了中止無限循環代碼的能力。
至於代碼是如何插入的,下一節會給出 babel 插件代碼。
現在的問題是,使用執行時長作爲判斷條件,是否合理?上面的第二個用例「無限渲染」很明顯就不正確,另外涉及異步場景,也依然有問題。
for(let i=0;i<10;i++){
await fetch('/xxx')
}
用頻率代替時長
我們可以換個思路,統計兩次循環之間的間隔。若足夠小,說明是同步代碼死循環;若足夠大,說明是異步循環調用,可以不用考慮。
關於足夠小,我們可以粗淺的以 4ms 作爲界限。通常來說, 1ms 能夠執行數百次指令,只要循環體中的代碼不是非常複雜,通常都能夠在 4ms 內返回。再加入最大執行次數進行綜合判斷
while(true) {
// 死循環
}
// 調整爲
const MAX_ITERATIONS = 2000 // 最大可循環次數
const MAX_INTERVAL = 4 // 最大執行間隔
let lastDate = Date.now()
let loopCount = 0
while(true) {
loopCount++
if(Date.now() - lastDate <= MAX_INTERVAL && loopCount % MAX_ITERATIONS === 0) {
throw new RangeError('Potential infinite loop: exceeded')
} else {
lastDate = Date.now()
}
// 死循環
}
Babel 處理
根據上面的分析,我們可以使用 babel 寫一個插件快速驗證
關於 babel 插件的知識,可以查看中文官方文檔
const MAX_ITERATIONS = 2000; // 最大迭代次數
const MAX_INTERVAL = 4; // 最大執行間隔
module.exports = ({ types: t, template }) => {
// 生成循環體判斷條件
const buildGuard = template(`
%%iterator%%++
if (%%iterator%% % %%maxIterations%% === 0 && Date.now() - %%lastDate%% <= %%maxInterval%%) {
throw new RangeError('Potential infinite loop: exceeded ');
} else {
%%lastDate%% = Date.now()
}
`);
return {
visitor: {
"WhileStatement|ForStatement|DoWhileStatement": (path) => {
// 新增變量:執行次數
const iterator = path.scope.parent.generateUidIdentifier("loopIt");
const iteratorInit = t.numericLiteral(0);
path.scope.parent.push({
id: iterator,
init: iteratorInit,
});
// 新增變量:上次執行時間
const lastDate = path.scope.parent.generateUidIdentifier("lastDate");
const lastDateInit = t.callExpression(
t.memberExpression(t.identifier("Date"), t.identifier("now")),
[]
);
path.scope.parent.push({
id: lastDate,
init: lastDateInit,
});
// 插入循環體
const guard = buildGuard({
iterator,
maxIterations: t.numericLiteral(MAX_ITERATIONS),
lastDate,
maxInterval: t.numericLiteral(MAX_INTERVAL)
});
// 處理 No block statement 的情況,比如 `while (1) 1;`
if (!path.get("body").isBlockStatement()) {
const statement = path.get("body").node;
path.get("body").replaceWith(t.blockStatement([guard, statement]));
} else {
path.get("body").unshiftContainer("body", guard);
}
},
// 類組件函數,略
ClassDeclaration: (path, file) => {},
// 箭頭函數組件,略
VariableDeclaration: (path, file) => {
// 判斷是否爲 JSX 函數,可以通過 ReturnStatement 是否爲 JSXFragment/JSXElement 進行判斷
},
// 普通函數組件,略
FunctionDeclaration: (path, file) => {},
},
};
};
測試一下
const babel = require("@babel/core");
// 測試插件
const code = `
while(true){
for(;;) {
}
}
`;
const result = babel.transformSync(code, {
plugins: [require("./plugin")],
// presets: ["@babel/preset-env"],
});
console.log(result.code);
得到如下輸出
"use strict";
var _loopIt = 0,
_lastDate = Date.now();
while (true) {
var _loopIt2 = 0,
_lastDate2 = Date.now();
_loopIt++;
if (_loopIt % 2000 === 0 && Date.now() - _lastDate <= 4) {
throw new RangeError('Potential infinite loop: exceeded ');
} else {
_lastDate = Date.now();
}
for (;;) {
_loopIt2++;
if (_loopIt2 % 2000 === 0 && Date.now() - _lastDate2 <= 4) {
throw new RangeError('Potential infinite loop: exceeded ');
} else {
_lastDate2 = Date.now();
}
}
}
正好滿足我們的需求。
最後
需要再次聲明的是,本文提供的方案僅處理了常見了無限循環用例。
在實際項目中,用戶可以通過 eval
、 new Function
等各種方案脫離這個檢測機制,難以完全避免。
此時可能需要想的是,用戶都這麼寫了,那我們還需要爲他考慮麼?
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/PD5YLl6WdIVIAHI7f0rP4g