Skip to content
Merged
44 changes: 1 addition & 43 deletions client/modules/Preview/EmbedFrame.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import blobUtil from 'blob-util';
import PropTypes from 'prop-types';
import React, { useRef, useEffect, useMemo } from 'react';
import styled from 'styled-components';
import loopProtect from 'loop-protect';
import decomment from 'decomment';
import { jsPreprocess } from './jsPreprocess';
import { resolvePathToFile } from '../../../server/utils/filePath';
import { getConfig } from '../../utils/getConfig';
import {
Expand Down Expand Up @@ -55,47 +54,6 @@ function resolveCSSLinksInString(content, files) {
return newContent;
}

function jsPreprocess(jsText) {
let newContent = jsText;

// Skip loop protection if the user explicitly opts out with // noprotect
if (/\/\/\s*noprotect/.test(newContent)) {
return newContent;
}

// Detect and fix multiple consecutive loops on the same line (e.g. "for(){}for(){}")
// which can bypass loop protection. Add semicolons between them so each loop
// is properly wrapped by loopProtect. See #3891.
// Match: for/while/do-while loops followed immediately by another loop
newContent = newContent.replace(
/((?:for|while)\s*\([^)]*\)\s*\{[^}]*\})((?:for|while)\s*\([^)]*\)\s*\{[^}]*\})/g,
'$1; $2'
);

// Always apply loop protection to prevent infinite loops from crashing
// the browser tab. Previously, loop protection was skipped when JSHINT
// found errors, but this left users vulnerable to infinite loops in
// syntactically imperfect code (common while typing). See #3891.
try {
newContent = decomment(newContent, {
ignore: /\/\/\s*noprotect/g,
space: true
});
newContent = loopProtect(newContent);
} catch (e) {
// If decomment or loopProtect fails (e.g. due to syntax issues),
// still try to apply loop protection on the original code.
try {
newContent = loopProtect(jsText);
} catch (err) {
// If loop protection can't be applied at all, return original code.
// The sketch will still run, but without loop protection.
return jsText;
}
}
return newContent;
}

function resolveJSLinksInString(content, files) {
let newContent = content;
let jsFileStrings = content.match(STRING_REGEX);
Expand Down
132 changes: 132 additions & 0 deletions client/modules/Preview/jsPreprocess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import * as acorn from 'acorn';
import * as walk from 'acorn-walk';

const LOOP_TIMEOUT_MS = 100;

function isShaderCall(node) {
const { callee } = node;
const isBuildShader =
callee.type === 'Identifier' && /^build\w*Shader$/.test(callee.name);
const isBaseShaderModify =
callee.type === 'MemberExpression' &&
callee.property.name === 'modify' &&
callee.object.type === 'CallExpression' &&
callee.object.callee.type === 'Identifier' &&
/^base\w*Shader$/.test(callee.object.callee.name);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth noting that this might miss some things, e.g. if a plugin author exposes a shader of their own that has its own name or is just a variable and not a function. If we just check that it's a MemberExpression calling a property called modify that would capture all that, but may have some false positives if users have a method called modify in non-p5.strands code. I think either could be a reasonable compromise, @raclim let me know if you have any thoughts on which side to err on!

Copy link
Copy Markdown
Member Author

@Nixxx19 Nixxx19 Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, thank you! updated it to match any .modify() call instead of just base*Shader().modify(), which should cover plugin-defined shaders too. there could be false positives if someone has an unrelated .modify() call, but that feels like the safer tradeoff. happy to hear your thoughts on this.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @davepagurek's suggestion sounds good to me! I think we can keep the check broad for now, and refine it later down the line if needed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you both for the input! will keep it broad for now and revisit if false positives come up.

return isBuildShader || isBaseShaderModify;
}

function collectShaderFunctionNames(ast) {
const names = new Set();
walk.simple(ast, {
CallExpression(node) {
if (isShaderCall(node)) {
node.arguments.forEach((arg) => {
if (arg.type === 'Identifier') {
names.add(arg.name);
}
});
}
}
});
return names;
}

function collectLoopsToProtect(ast, shaderNames) {
const loops = [];

function visitNode(node, ancestors) {
const isInsideShader = ancestors.some((ancestor, idx) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, looks good!

Copy link
Copy Markdown
Member Author

@Nixxx19 Nixxx19 Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you!

if (
ancestor.type === 'FunctionDeclaration' &&
shaderNames.has(ancestor.id?.name)
) {
return true;
}
if (
ancestor.type === 'FunctionExpression' ||
ancestor.type === 'ArrowFunctionExpression'
) {
const parent = ancestors[idx - 1];
if (
parent?.type === 'CallExpression' &&
isShaderCall(parent) &&
parent.arguments.includes(ancestor)
) {
return true;
}
if (
parent?.type === 'VariableDeclarator' &&
shaderNames.has(parent.id?.name)
) {
return true;
}
}
return false;
});

if (!isInsideShader) loops.push(node);
}

walk.ancestor(ast, {
ForStatement: visitNode,
WhileStatement: visitNode,
DoWhileStatement: visitNode
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there anything else(i.e for...of loops) we might want to cover here? Also okay with leaving it as is!

Copy link
Copy Markdown
Member Author

@Nixxx19 Nixxx19 Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for...of and for...in loops are parsed as separate ast node types (ForOfStatement and ForInStatement), so they are not currently covered. happy to add support for both if you think that would be a good addition!

});

return loops;
}

export function jsPreprocess(jsText) {
if (/\/\/\s*noprotect/.test(jsText)) {
return jsText;
}

let ast;
try {
ast = acorn.parse(jsText, {
ecmaVersion: 'latest',
sourceType: 'script',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth considering that users could use <script type="module"> scripts, e.g. https://editor.p5js.org/davepagurek/sketches/-foIv7eQa -- worth testing to see if parsing fails in those if you use sourceType: 'script' and you have an import statement, or if we'd need to pass in the source type based on how the script tag imports it.

Copy link
Copy Markdown
Member Author

@Nixxx19 Nixxx19 Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for catching this! added a fallback that tries sourceType: 'script' first, and if that throws (e.g. for a module script with import statements), it retries with sourceType: 'module'. added a test for this case too.

locations: true
});
} catch (e) {
return jsText;
}

const shaderNames = collectShaderFunctionNames(ast);
const loops = collectLoopsToProtect(ast, shaderNames);

if (loops.length === 0) return jsText;

const insertions = [];

loops.forEach((loop) => {
const { line } = loop.loc.start;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly worth considering whether it's preferable to still do line-based insertions like this, which may have issues with multiple loops on the same line like what's implied by the comment in the current implementation:

// Detect and fix multiple consecutive loops on the same line (e.g. "for(){}for(){}")
// which can bypass loop protection. Add semicolons between them so each loop
// is properly wrapped by loopProtect. See #3891.
// Match: for/while/do-while loops followed immediately by another loop

...or if instead it would be better to make changes at the AST level using Acorn by modifying the AST nodes, and then use a tool like escodegen to re-emit the full source code from that. (This is what we do in p5.strands after transpiling shader functions: https://github.com/processing/p5.js/blob/9498619e7b31f1b3a4e0fda5fcdc19d38e7eff20/src/strands/strands_transpiler.js#L1772) The downside of this approach is that escodegen doesn't preserve everything about your source code like whitespace and semicolon usage, so it might look a little different if you put a debugger statement and look at the code actually being run by the browser. Also it would mean adding another library, not sure how much that adds to the bundle size. But it would mean more targeted insertions and less likelihood for it happening incorrectly.

Copy link
Copy Markdown
Member Author

@Nixxx19 Nixxx19 Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for the suggestion! switched to the escodegen approach. we now modify the ast nodes directly and regenerate the source using escodegen.generate(). added escodegen as a dependency. the tradeoff on whitespace/formatting is real but feels worth it for the correctness guarantees. please let me know if the bundle size is a concern and we can fall back to character-offset string insertions instead, which also handles multiple loops on the same line correctly since we apply them back-to-front.

const varName = `_LP${loop.start}`;

const beforeCode = `var ${varName} = Date.now(); `;

const checkCode =
`if (Date.now() - ${varName} > ${LOOP_TIMEOUT_MS}) ` +
`{ window.loopProtect.hit(${line}); break; } `;

const { body } = loop;
if (body.type === 'BlockStatement') {
insertions.push({ pos: loop.start, code: beforeCode });
insertions.push({ pos: body.start + 1, code: checkCode });
} else {
insertions.push({ pos: loop.start, code: beforeCode });
insertions.push({ pos: body.start, code: `{ ${checkCode}` });
insertions.push({ pos: body.end, code: ` }` });
}
});

insertions.sort((a, b) => b.pos - a.pos);

let result = jsText;
insertions.forEach(({ pos, code }) => {
result = result.slice(0, pos) + code + result.slice(pos);
});

return result;
}
129 changes: 129 additions & 0 deletions client/modules/Preview/jsPreprocess.unit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { jsPreprocess } from './jsPreprocess';

describe('jsPreprocess', () => {
describe('// noprotect', () => {
it('returns code unchanged when // noprotect is present', () => {
const code = '// noprotect\nfor (let i = 0; i < 10; i++) {}';
expect(jsPreprocess(code)).toBe(code);
});
});

describe('regular loop protection', () => {
it('adds loop protection to a for loop', () => {
const code = 'for (let i = 0; i < 10; i++) {}';
const result = jsPreprocess(code);
expect(result).toContain('window.loopProtect.hit');
expect(result).toContain('Date.now()');
});

it('adds loop protection to a while loop', () => {
const code = 'while (true) {}';
const result = jsPreprocess(code);
expect(result).toContain('window.loopProtect.hit');
});

it('adds loop protection to a do-while loop', () => {
const code = 'do {} while (true)';
const result = jsPreprocess(code);
expect(result).toContain('window.loopProtect.hit');
});

it('returns original code if transform fails', () => {
const code = 'this is not valid javascript @@@@';
expect(jsPreprocess(code)).toBe(code);
});
});

describe('shader strings', () => {
it('does not modify for loops inside template literal shader strings', () => {
const code = `
const shader = \`
for (int i = 0; i < 20; i++) {
total += texture(tex, uv);
}
\`;
`;
const result = jsPreprocess(code);
expect(result).not.toContain('window.loopProtect.hit');
});

it('does not modify for loops inside single-quoted strings', () => {
const code = "const glsl = 'for (int i = 0; i < 10; i++) {}';";
const result = jsPreprocess(code);
expect(result).not.toContain('window.loopProtect.hit');
});

it('does not modify GLSL inside buildFilterShader object argument', () => {
const code = `
blur = buildFilterShader({
'vec4 getColor': \`(FilterInputs inputs) {
for (int i = 0; i < 20; i++) {
total += getTexture(canvasContent, inputs.texCoord);
}
}\`
});
`;
const result = jsPreprocess(code);
expect(result).not.toContain('window.loopProtect.hit');
});
});

describe('p5.strands shader functions', () => {
it('skips loop protection for named function passed to buildFilterShader', () => {
const code = `
blur = buildFilterShader(doBlur);
function doBlur() {
for (let i = 0; i < 20; i++) {}
}
`;
const result = jsPreprocess(code);
expect(result).not.toContain('window.loopProtect.hit');
});

it('skips loop protection for arrow function variable passed to buildFilterShader', () => {
const code = `
const doBlur = () => {
for (let i = 0; i < 20; i++) {}
};
blur = buildFilterShader(doBlur);
`;
const result = jsPreprocess(code);
expect(result).not.toContain('window.loopProtect.hit');
});

it('skips loop protection for inline function passed to buildFilterShader', () => {
const code = `
blur = buildFilterShader(function() {
for (let i = 0; i < 20; i++) {}
});
`;
const result = jsPreprocess(code);
expect(result).not.toContain('window.loopProtect.hit');
});

it('skips loop protection for function passed to base*Shader().modify()', () => {
const code = `
blur = baseFilterShader().modify(doBlur);
function doBlur() {
for (let i = 0; i < 20; i++) {}
}
`;
const result = jsPreprocess(code);
expect(result).not.toContain('window.loopProtect.hit');
});

it('still protects regular loops outside shader functions', () => {
const code = `
blur = buildFilterShader(doBlur);
function doBlur() {
for (let i = 0; i < 20; i++) {}
}
function draw() {
for (let j = 0; j < 100; j++) {}
}
`;
const result = jsPreprocess(code);
expect(result).toContain('window.loopProtect.hit');
});
});
});
31 changes: 9 additions & 22 deletions client/utils/previewEntry.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import loopProtect from 'loop-protect';
import { Hook, Decode, Encode } from 'console-feed';
import StackTrace from 'stacktrace-js';
import { evaluateExpression } from './evaluateExpression';
Expand All @@ -14,16 +13,13 @@ const htmlOffset = 12;
window.objectUrls[window.location.href] = '/index.html';
const blobPath = window.location.href.split('/').pop();
window.objectPaths[blobPath] = 'index.html';
// Monkey-patch loopProtect to send infinite loop warnings to the in-app console
window.loopProtect = loopProtect;
if (window.loopProtect && typeof window.loopProtect.hit === 'function') {
let hitCount = 0;
let lastHitTime = 0;
let firstLine = null;
let stopTimeout = null;
window.loopProtect.hit = function handleLoopHit(line) {
let hitCount = 0;
let lastHitTime = 0;
let firstLine = null;
let stopTimeout = null;
window.loopProtect = {
hit: function handleLoopHit(line) {
const now = Date.now();
// Reset counters if more than 1 second has passed
if (now - lastHitTime > 1000) {
hitCount = 0;
firstLine = null;
Expand All @@ -35,37 +31,28 @@ if (window.loopProtect && typeof window.loopProtect.hit === 'function') {
hitCount++;
lastHitTime = now;

// Track first line for single loop case
if (hitCount === 1) {
firstLine = line;
// Wait briefly to see if more loops are detected (minimal delay)
stopTimeout = setTimeout(() => {
if (hitCount === 1) {
// Only one loop detected - show line number
const msg = `Infinite loop detected at line ${firstLine}. Stopping execution.`;
throw new Error(msg);
}
// If hitCount > 1, another loop already threw the error
}, 30);
}

// If multiple loops detected, stop immediately without waiting
if (hitCount > 1) {
// Clear single loop timeout since we have multiple
if (stopTimeout) {
clearTimeout(stopTimeout);
stopTimeout = null;
}
// Stop immediately - multiple loops exist
const msg = 'Multiple infinite loops detected. Stopping execution.';
throw new Error(msg);
}

// Don't call origHit to prevent duplicate messages
// The loop protection still works, we just handle the messaging ourselves
return true; // Return true to indicate loop was detected
};
}
return true;
}
};

const consoleBuffer = [];
const LOGWAIT = 500;
Expand Down
Loading