一文详解如何检测并解决JS代码中的死循环
作者:francecil
背景
之前做的一个需求,需要探测用户 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
等各种方案脱离这个检测机制,难以完全避免。
此时可能需要想的是,用户都这么写了,那我们还需要为他考虑么?
到此这篇关于一文详解如何检测并解决JS代码中的死循环的文章就介绍到这了,更多相关JS死循环内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!