Skip to content

Commit 6273dca

Browse files
committed
fix loop protection breaking shader strings and p5.strands functions
1 parent 9e1b0c5 commit 6273dca

4 files changed

Lines changed: 271 additions & 65 deletions

File tree

client/modules/Preview/EmbedFrame.jsx

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import blobUtil from 'blob-util';
22
import PropTypes from 'prop-types';
33
import React, { useRef, useEffect, useMemo } from 'react';
44
import styled from 'styled-components';
5-
import loopProtect from 'loop-protect';
6-
import decomment from 'decomment';
5+
import { jsPreprocess } from './jsPreprocess';
76
import { resolvePathToFile } from '../../../server/utils/filePath';
87
import { getConfig } from '../../utils/getConfig';
98
import {
@@ -55,47 +54,6 @@ function resolveCSSLinksInString(content, files) {
5554
return newContent;
5655
}
5756

58-
function jsPreprocess(jsText) {
59-
let newContent = jsText;
60-
61-
// Skip loop protection if the user explicitly opts out with // noprotect
62-
if (/\/\/\s*noprotect/.test(newContent)) {
63-
return newContent;
64-
}
65-
66-
// Detect and fix multiple consecutive loops on the same line (e.g. "for(){}for(){}")
67-
// which can bypass loop protection. Add semicolons between them so each loop
68-
// is properly wrapped by loopProtect. See #3891.
69-
// Match: for/while/do-while loops followed immediately by another loop
70-
newContent = newContent.replace(
71-
/((?:for|while)\s*\([^)]*\)\s*\{[^}]*\})((?:for|while)\s*\([^)]*\)\s*\{[^}]*\})/g,
72-
'$1; $2'
73-
);
74-
75-
// Always apply loop protection to prevent infinite loops from crashing
76-
// the browser tab. Previously, loop protection was skipped when JSHINT
77-
// found errors, but this left users vulnerable to infinite loops in
78-
// syntactically imperfect code (common while typing). See #3891.
79-
try {
80-
newContent = decomment(newContent, {
81-
ignore: /\/\/\s*noprotect/g,
82-
space: true
83-
});
84-
newContent = loopProtect(newContent);
85-
} catch (e) {
86-
// If decomment or loopProtect fails (e.g. due to syntax issues),
87-
// still try to apply loop protection on the original code.
88-
try {
89-
newContent = loopProtect(jsText);
90-
} catch (err) {
91-
// If loop protection can't be applied at all, return original code.
92-
// The sketch will still run, but without loop protection.
93-
return jsText;
94-
}
95-
}
96-
return newContent;
97-
}
98-
9957
function resolveJSLinksInString(content, files) {
10058
let newContent = content;
10159
let jsFileStrings = content.match(STRING_REGEX);
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import * as acorn from 'acorn';
2+
import * as walk from 'acorn-walk';
3+
4+
const LOOP_TIMEOUT_MS = 100;
5+
6+
function isShaderCall(node) {
7+
const { callee } = node;
8+
const isBuildShader =
9+
callee.type === 'Identifier' && /^build\w*Shader$/.test(callee.name);
10+
const isBaseShaderModify =
11+
callee.type === 'MemberExpression' &&
12+
callee.property.name === 'modify' &&
13+
callee.object.type === 'CallExpression' &&
14+
callee.object.callee.type === 'Identifier' &&
15+
/^base\w*Shader$/.test(callee.object.callee.name);
16+
return isBuildShader || isBaseShaderModify;
17+
}
18+
19+
function collectShaderFunctionNames(ast) {
20+
const names = new Set();
21+
walk.simple(ast, {
22+
CallExpression(node) {
23+
if (isShaderCall(node)) {
24+
node.arguments.forEach((arg) => {
25+
if (arg.type === 'Identifier') {
26+
names.add(arg.name);
27+
}
28+
});
29+
}
30+
}
31+
});
32+
return names;
33+
}
34+
35+
function collectLoopsToProtect(ast, shaderNames) {
36+
const loops = [];
37+
38+
function visitNode(node, ancestors) {
39+
const isInsideShader = ancestors.some((ancestor, idx) => {
40+
if (
41+
ancestor.type === 'FunctionDeclaration' &&
42+
shaderNames.has(ancestor.id?.name)
43+
) {
44+
return true;
45+
}
46+
if (
47+
ancestor.type === 'FunctionExpression' ||
48+
ancestor.type === 'ArrowFunctionExpression'
49+
) {
50+
const parent = ancestors[idx - 1];
51+
if (
52+
parent?.type === 'CallExpression' &&
53+
isShaderCall(parent) &&
54+
parent.arguments.includes(ancestor)
55+
) {
56+
return true;
57+
}
58+
if (
59+
parent?.type === 'VariableDeclarator' &&
60+
shaderNames.has(parent.id?.name)
61+
) {
62+
return true;
63+
}
64+
}
65+
return false;
66+
});
67+
68+
if (!isInsideShader) loops.push(node);
69+
}
70+
71+
walk.ancestor(ast, {
72+
ForStatement: visitNode,
73+
WhileStatement: visitNode,
74+
DoWhileStatement: visitNode
75+
});
76+
77+
return loops;
78+
}
79+
80+
export function jsPreprocess(jsText) {
81+
if (/\/\/\s*noprotect/.test(jsText)) {
82+
return jsText;
83+
}
84+
85+
let ast;
86+
try {
87+
ast = acorn.parse(jsText, {
88+
ecmaVersion: 'latest',
89+
sourceType: 'script',
90+
locations: true
91+
});
92+
} catch (e) {
93+
return jsText;
94+
}
95+
96+
const shaderNames = collectShaderFunctionNames(ast);
97+
const loops = collectLoopsToProtect(ast, shaderNames);
98+
99+
if (loops.length === 0) return jsText;
100+
101+
const insertions = [];
102+
103+
loops.forEach((loop) => {
104+
const { line } = loop.loc.start;
105+
const varName = `_LP${loop.start}`;
106+
107+
const beforeCode = `var ${varName} = Date.now(); `;
108+
109+
const checkCode =
110+
`if (Date.now() - ${varName} > ${LOOP_TIMEOUT_MS}) ` +
111+
`{ window.loopProtect.hit(${line}); break; } `;
112+
113+
const { body } = loop;
114+
if (body.type === 'BlockStatement') {
115+
insertions.push({ pos: loop.start, code: beforeCode });
116+
insertions.push({ pos: body.start + 1, code: checkCode });
117+
} else {
118+
insertions.push({ pos: loop.start, code: beforeCode });
119+
insertions.push({ pos: body.start, code: `{ ${checkCode}` });
120+
insertions.push({ pos: body.end, code: ` }` });
121+
}
122+
});
123+
124+
insertions.sort((a, b) => b.pos - a.pos);
125+
126+
let result = jsText;
127+
insertions.forEach(({ pos, code }) => {
128+
result = result.slice(0, pos) + code + result.slice(pos);
129+
});
130+
131+
return result;
132+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { jsPreprocess } from './jsPreprocess';
2+
3+
describe('jsPreprocess', () => {
4+
describe('// noprotect', () => {
5+
it('returns code unchanged when // noprotect is present', () => {
6+
const code = '// noprotect\nfor (let i = 0; i < 10; i++) {}';
7+
expect(jsPreprocess(code)).toBe(code);
8+
});
9+
});
10+
11+
describe('regular loop protection', () => {
12+
it('adds loop protection to a for loop', () => {
13+
const code = 'for (let i = 0; i < 10; i++) {}';
14+
const result = jsPreprocess(code);
15+
expect(result).toContain('window.loopProtect.hit');
16+
expect(result).toContain('Date.now()');
17+
});
18+
19+
it('adds loop protection to a while loop', () => {
20+
const code = 'while (true) {}';
21+
const result = jsPreprocess(code);
22+
expect(result).toContain('window.loopProtect.hit');
23+
});
24+
25+
it('adds loop protection to a do-while loop', () => {
26+
const code = 'do {} while (true)';
27+
const result = jsPreprocess(code);
28+
expect(result).toContain('window.loopProtect.hit');
29+
});
30+
31+
it('returns original code if transform fails', () => {
32+
const code = 'this is not valid javascript @@@@';
33+
expect(jsPreprocess(code)).toBe(code);
34+
});
35+
});
36+
37+
describe('shader strings', () => {
38+
it('does not modify for loops inside template literal shader strings', () => {
39+
const code = `
40+
const shader = \`
41+
for (int i = 0; i < 20; i++) {
42+
total += texture(tex, uv);
43+
}
44+
\`;
45+
`;
46+
const result = jsPreprocess(code);
47+
expect(result).not.toContain('window.loopProtect.hit');
48+
});
49+
50+
it('does not modify for loops inside single-quoted strings', () => {
51+
const code = "const glsl = 'for (int i = 0; i < 10; i++) {}';";
52+
const result = jsPreprocess(code);
53+
expect(result).not.toContain('window.loopProtect.hit');
54+
});
55+
56+
it('does not modify GLSL inside buildFilterShader object argument', () => {
57+
const code = `
58+
blur = buildFilterShader({
59+
'vec4 getColor': \`(FilterInputs inputs) {
60+
for (int i = 0; i < 20; i++) {
61+
total += getTexture(canvasContent, inputs.texCoord);
62+
}
63+
}\`
64+
});
65+
`;
66+
const result = jsPreprocess(code);
67+
expect(result).not.toContain('window.loopProtect.hit');
68+
});
69+
});
70+
71+
describe('p5.strands shader functions', () => {
72+
it('skips loop protection for named function passed to buildFilterShader', () => {
73+
const code = `
74+
blur = buildFilterShader(doBlur);
75+
function doBlur() {
76+
for (let i = 0; i < 20; i++) {}
77+
}
78+
`;
79+
const result = jsPreprocess(code);
80+
expect(result).not.toContain('window.loopProtect.hit');
81+
});
82+
83+
it('skips loop protection for arrow function variable passed to buildFilterShader', () => {
84+
const code = `
85+
const doBlur = () => {
86+
for (let i = 0; i < 20; i++) {}
87+
};
88+
blur = buildFilterShader(doBlur);
89+
`;
90+
const result = jsPreprocess(code);
91+
expect(result).not.toContain('window.loopProtect.hit');
92+
});
93+
94+
it('skips loop protection for inline function passed to buildFilterShader', () => {
95+
const code = `
96+
blur = buildFilterShader(function() {
97+
for (let i = 0; i < 20; i++) {}
98+
});
99+
`;
100+
const result = jsPreprocess(code);
101+
expect(result).not.toContain('window.loopProtect.hit');
102+
});
103+
104+
it('skips loop protection for function passed to base*Shader().modify()', () => {
105+
const code = `
106+
blur = baseFilterShader().modify(doBlur);
107+
function doBlur() {
108+
for (let i = 0; i < 20; i++) {}
109+
}
110+
`;
111+
const result = jsPreprocess(code);
112+
expect(result).not.toContain('window.loopProtect.hit');
113+
});
114+
115+
it('still protects regular loops outside shader functions', () => {
116+
const code = `
117+
blur = buildFilterShader(doBlur);
118+
function doBlur() {
119+
for (let i = 0; i < 20; i++) {}
120+
}
121+
function draw() {
122+
for (let j = 0; j < 100; j++) {}
123+
}
124+
`;
125+
const result = jsPreprocess(code);
126+
expect(result).toContain('window.loopProtect.hit');
127+
});
128+
});
129+
});

client/utils/previewEntry.js

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import loopProtect from 'loop-protect';
21
import { Hook, Decode, Encode } from 'console-feed';
32
import StackTrace from 'stacktrace-js';
43
import { evaluateExpression } from './evaluateExpression';
@@ -14,16 +13,13 @@ const htmlOffset = 12;
1413
window.objectUrls[window.location.href] = '/index.html';
1514
const blobPath = window.location.href.split('/').pop();
1615
window.objectPaths[blobPath] = 'index.html';
17-
// Monkey-patch loopProtect to send infinite loop warnings to the in-app console
18-
window.loopProtect = loopProtect;
19-
if (window.loopProtect && typeof window.loopProtect.hit === 'function') {
20-
let hitCount = 0;
21-
let lastHitTime = 0;
22-
let firstLine = null;
23-
let stopTimeout = null;
24-
window.loopProtect.hit = function handleLoopHit(line) {
16+
let hitCount = 0;
17+
let lastHitTime = 0;
18+
let firstLine = null;
19+
let stopTimeout = null;
20+
window.loopProtect = {
21+
hit: function handleLoopHit(line) {
2522
const now = Date.now();
26-
// Reset counters if more than 1 second has passed
2723
if (now - lastHitTime > 1000) {
2824
hitCount = 0;
2925
firstLine = null;
@@ -35,37 +31,28 @@ if (window.loopProtect && typeof window.loopProtect.hit === 'function') {
3531
hitCount++;
3632
lastHitTime = now;
3733

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

52-
// If multiple loops detected, stop immediately without waiting
5344
if (hitCount > 1) {
54-
// Clear single loop timeout since we have multiple
5545
if (stopTimeout) {
5646
clearTimeout(stopTimeout);
5747
stopTimeout = null;
5848
}
59-
// Stop immediately - multiple loops exist
6049
const msg = 'Multiple infinite loops detected. Stopping execution.';
6150
throw new Error(msg);
6251
}
6352

64-
// Don't call origHit to prevent duplicate messages
65-
// The loop protection still works, we just handle the messaging ourselves
66-
return true; // Return true to indicate loop was detected
67-
};
68-
}
53+
return true;
54+
}
55+
};
6956

7057
const consoleBuffer = [];
7158
const LOGWAIT = 500;

0 commit comments

Comments
 (0)