Skip to content

Instantly share code, notes, and snippets.

@chriscoyier
Last active February 15, 2023 00:09
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save chriscoyier/b805fe91cc832fc97a3af671051e501f to your computer and use it in GitHub Desktop.
Save chriscoyier/b805fe91cc832fc97a3af671051e501f to your computer and use it in GitHub Desktop.
The JS Instrumenting we do at CodePen
var esprima = require('esprima');
function instrument(code) {
var LOOP_CHECK = 'if (window.CP.shouldStopExecution(%d)){break;}';
var LOOP_EXIT = "\nwindow.CP.exitedLoop(%d);\n";
var loopId = 1;
var patches = [];
esprima.parse(code, {
range: true,
tolerant: false,
sourceType: "script",
jsx: true
}, function (node) {
switch (node.type) {
case 'DoWhileStatement':
case 'ForStatement':
case 'ForInStatement':
case 'ForOfStatement':
case 'WhileStatement':
var start = 1 + node.body.range[0];
var end = node.body.range[1];
var prolog = LOOP_CHECK.replace('%d', loopId);
var epilog = '';
if (node.body.type !== 'BlockStatement') {
// `while(1) doThat()` becomes `while(1) {doThat()}`
prolog = '{' + prolog;
epilog = '}';
--start;
}
patches.push({ pos: start, str: prolog });
patches.push({ pos: end, str: epilog });
patches.push({ pos: node.range[1], str: LOOP_EXIT.replace('%d', loopId) });
++loopId;
break;
default:
break;
}
});
patches.sort(function (a, b) {
return b.pos - a.pos;
}).forEach(function (patch) {
code = code.slice(0, patch.pos) + patch.str + code.slice(patch.pos);
});
return code;
}
exports.handler = function(event, context) {
try {
var js = event.markup || "";
if (js === "") {
context.succeed({
"markup": ""
});
} else {
context.succeed({
"markup": instrument(event.markup)
});
}
} catch(e) {
var line = 1;
try {
line = e.lineNumber;
} catch(err) {
// go on with line number 1
}
context.succeed({
"error": e.description,
"line": line
});
}
};
"use strict";
if (typeof (window.CP) !== "object") {
window.CP = {};
}
window.CP.PenTimer = {
// If we successfully run for X seconds no need to continue
// to monitor because we know the program isn't locked
programNoLongerBeingMonitored: false,
timeOfFirstCallToShouldStopLoop: 0,
_loopExits: {},
// Keep track of how long program spends in single loop w/o an exit
_loopTimers: {},
// Give the program time to get started
START_MONITORING_AFTER: 2000,
// takes into account START_MONITORING_AFTER val
STOP_ALL_MONITORING_TIMEOUT: 5000,
// tested against pen: xbwYNm, it loops over 200k real loop
MAX_TIME_IN_LOOP_WO_EXIT: 2200,
exitedLoop: function(loopID) {
this._loopExits[loopID] = true;
},
shouldStopLoop: function(loopID) {
// Once we kill a loop, kill them all, we have an infinite loop and
// it must be fixed prior to running again.
if (this.programKilledSoStopMonitoring) {
return true;
}
// Program exceeded monitor time, we're in the clear
if (this.programNoLongerBeingMonitored) {
return false;
}
// If the loopExit already called return
// It's possible for the program to break out
if (this._loopExits[loopID]) {
return false;
}
var now = this._getTime();
if (this.timeOfFirstCallToShouldStopLoop === 0) {
this.timeOfFirstCallToShouldStopLoop = now;
// first call to shouldStopLoop so just exit already
return false;
}
var programRunningTime = now - this.timeOfFirstCallToShouldStopLoop;
// Allow program to run unmolested (yup that's the right word)
// while it starts up
if (programRunningTime < this.START_MONITORING_AFTER) {
return false;
}
// Once the program's run for a satisfactory amount of time
// we assume it won't lock up and we can simply continue w/o
// checking for infinite loops
if (programRunningTime > this.STOP_ALL_MONITORING_TIMEOUT) {
this.programNoLongerBeingMonitored = true;
return false;
}
// Second level shit around new hotness logic
try {
this._checkOnInfiniteLoop(loopID, now);
} catch(e) {
this._sendErrorMessageToEditor();
this.programKilledSoStopMonitoring = true;
return true;
}
return false;
},
_sendErrorMessageToEditor: function() {
try {
if (this._shouldPostMessage()) {
var data = {
action: "infinite-loop",
line: this._findAroundLineNumber()
};
parent.postMessage(JSON.stringify(data), "*");
} else {
this._throwAnErrorToStopPen();
}
} catch(error) {
this._throwAnErrorToStopPen();
}
},
_shouldPostMessage: function() {
return document.location.href.match(/boomerang/);
},
_throwAnErrorToStopPen: function() {
throw "We found an infinite loop in your Pen. We've stopped the Pen from running. Please correct it or contact\ support@codepen.io.";
},
_findAroundLineNumber: function() {
var err = new Error();
var lineNumber = 0;
if (err.stack) {
// match only against JS in boomerang
var m = err.stack.match(/boomerang\S+:(\d+):\d+/);
if (m) {
lineNumber = m[1];
}
}
return lineNumber;
},
_checkOnInfiniteLoop: function(loopID, now) {
if (!this._loopTimers[loopID]) {
this._loopTimers[loopID] = now;
// We just started the timer for this loop. exit early
return false;
}
var loopRunningTime = now - this._loopTimers[loopID];
if (loopRunningTime > this.MAX_TIME_IN_LOOP_WO_EXIT) {
throw "Infinite Loop found on loop: " + loopID;
}
},
_getTime: function() {
return +new Date();
}
};
window.CP.shouldStopExecution = function(loopID) {
var shouldStop = window.CP.PenTimer.shouldStopLoop(loopID);
if( shouldStop === true ) {
console.warn("[CodePen]: An infinite loop (or a loop taking too long) was detected, so we stopped its execution. Sorry!");
}
return shouldStop;
};
window.CP.exitedLoop = function(loopID) {
window.CP.PenTimer.exitedLoop(loopID);
};
@ariya
Copy link

ariya commented Feb 2, 2017

I'm not entirely sure about the role and placement of window.CP.exitedLoop, but here is a possible alternative implementation (much much shorter, no dependency on escodegen). Error handling is left out for clarity.

For more technical details on the approach, refer also to Esprima documentation on Syntax Delegate:

var esprima = require('esprima');

exports.instrument = function(code) {
  var LOOP_CHECK = 'if (window.CP.shouldStopExecution(%d)) break; ';
  var LOOP_EXIT = '\nwindow.CP.exitedLoop(%d);\n'

  var loopId = 1;
  var patches = [];

  esprima.parse(code, { range: true }, function (node) {
    switch (node.type) {
      case 'DoWhileStatement':
      case 'ForStatement':
      case 'ForInStatement':
      case 'ForOfStatement':
      case 'WhileStatement':
        var start = 1 + node.body.range[0];
        var end = node.body.range[1];
        var prolog = LOOP_CHECK.replace('%d', loopId);
        var epilog = '';

        if (node.body.type !== 'BlockStatement') {
          // `while(1) doThat()` becomes `while(1) {doThat()}`
          prolog = '{' + prolog;
          epilog = '}';
          --start;
        }

        patches.push({ pos: start, str: prolog });
        patches.push({ pos: end, str: epilog });
        patches.push({ pos: node.range[1] + 1, str: LOOP_EXIT.replace('%d', loopId) });
        ++loopId;
        break;

      default:
        break;
    }
  });

  patches.sort(function (a, b) { return b.pos - a.pos }).forEach(function (patch) {
    code = code.slice(0, patch.pos) + patch.str + code.slice(patch.pos);
  });

  return code;
}

Edited: Apparently I did not paste the proper version, it is corrected now.

@quezo
Copy link

quezo commented Feb 2, 2017

@ariya This is excellent. This change makes the loop detection compatible with any version of JavaScript. Thank you for sharing this solution.

@ariya
Copy link

ariya commented Feb 3, 2017

@quezo Good to know it could be useful! Let me know if you encounter any problems.

@chriscoyier
Copy link
Author

chriscoyier commented Feb 4, 2017

@ariya - I'm working on implementing it right now!

I've found a little weirdness. Check this original JavaScript:

for (var x = 0; i < 10; x++) {
  for (var y = 0; y < 10; y++) {
    if (x === y) {

    } else if (x === 3) {

    }
  }
}

It comes back fairly malformed:

for (var x = 0; i < 10; x++) {if (window.CP.shouldStopExecutionif (window.CP.shouldStopExecution(1)) break;(2)) break;
  for (var y = 0; y < 10; y++) {
    if (x ===
window.CP.exitedLoop(1);
 y) {

    } else if (x === 3) {

    }
  }
}
window.CP.exitedLoop(2);

Is it the nesting of the for loops tripping it up?

@ariya
Copy link

ariya commented Feb 4, 2017

@chriscoyier seems that I did not paste the correct version, sorry for that. Try it again, it should be good now.

P.S.: Time to turn it into a simple git repo?

@noseratio
Copy link

@chriscoyier, @ariya FYI, this currently doesn't seem to work well for asynchronous loops, a simple example.: https://codepen.io/noseratio/pen/jRgYMQ.

for (var i = 0; i < 100 && !cancelled; i++) {
    contentDiv.innerText = `Page: ${i}`;
    await sleep(100);
};

It gets terminated after about ~40 iterations with this error: Uncaught (in promise) We found an infinite loop in your Pen. We've stopped the Pen from running. Please correct it or contact support@codepen.io.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment