-
-
Save chriscoyier/b805fe91cc832fc97a3af671051e501f to your computer and use it in GitHub Desktop.
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 This is excellent. This change makes the loop detection compatible with any version of JavaScript. Thank you for sharing this solution.
@quezo Good to know it could be useful! Let me know if you encounter any problems.
@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?
@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?
@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.
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:
Edited: Apparently I did not paste the proper version, it is corrected now.