如何检测并解决 JS 代码中的死循环?

背景

之前做的一个需求,需要探测用户 js 代码是否存在死循环。若发现死循环,则提前抛错,而不是继续执行直至线程卡死。

业界也有挺多类似的需求,比如 CodeSandbox 沙盒的 Infinite Loop Protection,可以避免用户在调试代码时写了死循环导致页面标签崩溃。

能否通过静态分析的方式检测出死循环?如果不能,我们又应该如何在不借用其他线程的情况下,解决死循环卡住问题?

下面就让我们一起来分析下这些问题吧。

死循环 Case

什么情况下会导致死循环?列举了常见的几种情况:

  1. 无限循环:循环条件始终为正 ,且循环体中没有中断语句
  2. 无限递归调用
  3. 无限渲染:表现在 React 等视图框架,渲染函数执行时又触发了数据变动
  4. ...

无限循环

js 复制代码
while (true) { // 循环条件也可能是一个很复杂、有外部入参的判断语句,但始终为正
  // 死循环
  if(1 !== 2) { // 中止条件永不触发
      return
  }
}

这类场景,循环条件始终为正,而在循环体中,要么没有中止条件,要么中止条件永远不触发,进而导致线程卡死。

无限递归调用

js 复制代码
(function recursive() {
  recursive(); // 死循环
})();

对于这类情况,执行引擎在达到最大递归调用栈深度后,便会抛出 RangeError ,我们无需主动处理。

arduino 复制代码
RangeError: Maximum call stack size exceeded

无限渲染

这里以 React 框架为例,在 render 函数中又触发了数据的变更。这边的用例比较直白,现实中的用例可能会非常隐蔽。

js 复制代码
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>;
  }
}
js 复制代码
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),那么这类用例必定是死循环。

js 复制代码
while(true) {
    // 死循环
}

然而这样的代码毕竟是少数,大部分用例是在不经意间写出死循环的,比如

js 复制代码
while (x > y && (x % 2 === 0 || y % 2 === 1)) {
  // 死循环,复杂条件难以分析
}

判断复杂、涉及外部输出,需要运行时分析,故纯静态分析难以判断

该问题在可计算性领域被称为停机问题,已被证明无法通过一个通用算法分析出一段代码是否存在死循环

运行时判断

既然静态分析无法解决,那么是否换个思路:给循环体加点判断代码,当循环次数过多或者循环执行过久的时候,就认为是死循环,并抛出异常。

我们先以执行过久 作为死循环判断条件 (后面会继续优化)

  • 对于无限循环的场景,可以这么处理:
js 复制代码
while(true) {
    // 死循环
}

// 调整为

let _loopStart = Date.now() 
while(true) {
    if(Date.now() - _loopStart > MAX_TIMEOUT) {
        throw new RangeError('Potential infinite loop: exceeded')
    }
    // 死循环
}

for 循环、do...while 循环同理转换。

  • 对于循环的场景,可以这么处理:
js 复制代码
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 插件代码。

现在的问题是,使用执行时长作为判断条件,是否合理?上面的第二个用例「无限渲染」很明显就不正确,另外涉及异步场景,也依然有问题。

js 复制代码
for(let i=0;i<10;i++){
    await fetch('/xxx')
}

用频率代替时长

我们可以换个思路,统计两次循环之间的间隔。若足够小,说明是同步代码死循环;若足够大,说明是异步循环调用,可以不用考虑。

关于足够小,我们可以粗浅的以 4ms 作为界限。通常来说, 1ms 能够执行数百次指令,只要循环体中的代码不是非常复杂,通常都能够在 4ms 内返回。再加入最大执行次数进行综合判断

js 复制代码
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 插件的知识,可以查看中文官方文档

js 复制代码
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) => {},
    },
  };
};

测试一下

js 复制代码
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);

得到如下输出

js 复制代码
"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 等各种方案脱离这个检测机制,难以完全避免。

此时可能需要想的是,用户都这么写了,那我们还需要为他考虑么?

拓展阅读

相关推荐
好看资源平台10 分钟前
前端框架对比与选择:如何在现代Web开发中做出最佳决策
前端·前端框架
4triumph13 分钟前
Vue.js教程笔记
前端·vue.js
程序员大金29 分钟前
基于SSM+Vue+MySQL的酒店管理系统
前端·vue.js·后端·mysql·spring·tomcat·mybatis
清灵xmf33 分钟前
提前解锁 Vue 3.5 的新特性
前端·javascript·vue.js·vue3.5
Jiaberrr39 分钟前
教你如何在微信小程序中轻松实现人脸识别功能
javascript·微信小程序·小程序·人脸识别·百度ai
白云~️1 小时前
监听html元素是否被删除,删除之后重新生成被删除的元素
前端·javascript·html
2401_864476931 小时前
无线领夹麦克风哪个降噪好?一文搞懂麦克风什么牌子的音质效果好
javascript·git·sql·github·mssql
金灰1 小时前
有关JS下隐藏的敏感信息
前端·网络·安全
Yxmeimei1 小时前
css实现居中的方法
前端·css·html
6230_1 小时前
git使用“保姆级”教程2——初始化及工作机制解释
开发语言·前端·笔记·git·html·学习方法·改行学it