寫一個 Babel 插件,檢測並解決 JS 代碼中的死循環

背景

之前做的一個需求,需要探測用戶 js 代碼是否存在死循環。若發現死循環,則提前拋錯,而不是繼續執行直至線程卡死。

業界也有挺多類似的需求,比如 CodeSandbox 沙盒的 Infinite Loop Protection,可以避免用戶在調試代碼時寫了死循環導致頁面標籤崩潰。

能否通過靜態分析的方式檢測出死循環?如果不能,我們又應該如何在不借用其他線程的情況下,解決死循環卡住問題?

下面就讓我們一起來分析下這些問題吧。

死循環 Case

什麼情況下會導致死循環?列舉了常見的幾種情況:

  1. 無限循環:循環條件始終爲正 ,且循環體中沒有中斷語句

  2. 無限遞歸調用

  3. 無限渲染:表現在 React 等視圖框架,渲染函數執行時又觸發了數據變動

  4. ...

無限循環

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();
    }
  }
}

正好滿足我們的需求。

最後

需要再次聲明的是,本文提供的方案僅處理了常見了無限循環用例。

在實際項目中,用戶可以通過 evalnew Function 等各種方案脫離這個檢測機制,難以完全避免。

此時可能需要想的是,用戶都這麼寫了,那我們還需要爲他考慮麼?

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/PD5YLl6WdIVIAHI7f0rP4g