Skip to content

Commit c5cdecb

Browse files
authored
Merge pull request #4083 from Nixxx19/nityam/fix-loop-protect-shader-strings-4080
fix loop protection breaking shader strings and p5.strands functions
2 parents a0f91f9 + 7cc69ea commit c5cdecb

6 files changed

Lines changed: 22364 additions & 26264 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: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import * as acorn from 'acorn';
2+
import * as walk from 'acorn-walk';
3+
import escodegen from 'escodegen';
4+
5+
const LOOP_TIMEOUT_MS = 100;
6+
7+
function isShaderCall(node) {
8+
const { callee } = node;
9+
const isBuildShader =
10+
callee.type === 'Identifier' && /^build\w*Shader$/.test(callee.name);
11+
const isModifyCall =
12+
callee.type === 'MemberExpression' && callee.property.name === 'modify';
13+
return isBuildShader || isModifyCall;
14+
}
15+
16+
function collectShaderFunctionNames(ast) {
17+
const names = new Set();
18+
walk.simple(ast, {
19+
CallExpression(node) {
20+
if (isShaderCall(node)) {
21+
node.arguments.forEach((arg) => {
22+
if (arg.type === 'Identifier') {
23+
names.add(arg.name);
24+
}
25+
});
26+
}
27+
}
28+
});
29+
return names;
30+
}
31+
32+
function makeVarDecl(varName) {
33+
return {
34+
type: 'VariableDeclaration',
35+
kind: 'var',
36+
declarations: [
37+
{
38+
type: 'VariableDeclarator',
39+
id: { type: 'Identifier', name: varName },
40+
init: {
41+
type: 'CallExpression',
42+
callee: {
43+
type: 'MemberExpression',
44+
object: { type: 'Identifier', name: 'Date' },
45+
property: { type: 'Identifier', name: 'now' },
46+
computed: false
47+
},
48+
arguments: []
49+
}
50+
}
51+
]
52+
};
53+
}
54+
55+
function makeCheckStatement(varName, line) {
56+
return {
57+
type: 'IfStatement',
58+
test: {
59+
type: 'BinaryExpression',
60+
operator: '>',
61+
left: {
62+
type: 'BinaryExpression',
63+
operator: '-',
64+
left: {
65+
type: 'CallExpression',
66+
callee: {
67+
type: 'MemberExpression',
68+
object: { type: 'Identifier', name: 'Date' },
69+
property: { type: 'Identifier', name: 'now' },
70+
computed: false
71+
},
72+
arguments: []
73+
},
74+
right: { type: 'Identifier', name: varName }
75+
},
76+
right: {
77+
type: 'Literal',
78+
value: LOOP_TIMEOUT_MS,
79+
raw: String(LOOP_TIMEOUT_MS)
80+
}
81+
},
82+
consequent: {
83+
type: 'BlockStatement',
84+
body: [
85+
{
86+
type: 'ExpressionStatement',
87+
expression: {
88+
type: 'CallExpression',
89+
callee: {
90+
type: 'MemberExpression',
91+
object: {
92+
type: 'MemberExpression',
93+
object: { type: 'Identifier', name: 'window' },
94+
property: { type: 'Identifier', name: 'loopProtect' },
95+
computed: false
96+
},
97+
property: { type: 'Identifier', name: 'hit' },
98+
computed: false
99+
},
100+
arguments: [{ type: 'Literal', value: line, raw: String(line) }]
101+
}
102+
},
103+
{ type: 'BreakStatement', label: null }
104+
]
105+
},
106+
alternate: null
107+
};
108+
}
109+
110+
function collectLoopsToProtect(ast, shaderNames) {
111+
const loops = [];
112+
113+
function visitNode(node, ancestors) {
114+
const isInsideShader = ancestors.some((ancestor, idx) => {
115+
if (
116+
ancestor.type === 'FunctionDeclaration' &&
117+
shaderNames.has(ancestor.id?.name)
118+
) {
119+
return true;
120+
}
121+
if (
122+
ancestor.type === 'FunctionExpression' ||
123+
ancestor.type === 'ArrowFunctionExpression'
124+
) {
125+
const parent = ancestors[idx - 1];
126+
if (
127+
parent?.type === 'CallExpression' &&
128+
isShaderCall(parent) &&
129+
parent.arguments.includes(ancestor)
130+
) {
131+
return true;
132+
}
133+
if (
134+
parent?.type === 'VariableDeclarator' &&
135+
shaderNames.has(parent.id?.name)
136+
) {
137+
return true;
138+
}
139+
}
140+
return false;
141+
});
142+
143+
if (isInsideShader) return;
144+
145+
let parentBlock = null;
146+
for (let i = ancestors.length - 1; i >= 0; i--) {
147+
const ancestor = ancestors[i];
148+
if (
149+
ancestor !== node &&
150+
(ancestor.type === 'BlockStatement' || ancestor.type === 'Program')
151+
) {
152+
parentBlock = ancestor;
153+
break;
154+
}
155+
}
156+
157+
loops.push({ loop: node, parentBlock });
158+
}
159+
160+
walk.ancestor(ast, {
161+
ForStatement: visitNode,
162+
WhileStatement: visitNode,
163+
DoWhileStatement: visitNode
164+
});
165+
166+
return loops;
167+
}
168+
169+
function injectProtection(loops) {
170+
loops.forEach(({ loop, parentBlock }, idx) => {
171+
const varName = `_LP${idx}`;
172+
const { line } = loop.loc.start;
173+
const check = makeCheckStatement(varName, line);
174+
175+
if (loop.body.type === 'BlockStatement') {
176+
loop.body.body.unshift(check);
177+
} else {
178+
loop.body = { type: 'BlockStatement', body: [check, loop.body] };
179+
}
180+
181+
if (parentBlock) {
182+
const varDecl = makeVarDecl(varName);
183+
const nodeIdx = parentBlock.body.indexOf(loop);
184+
if (nodeIdx !== -1) {
185+
parentBlock.body.splice(nodeIdx, 0, varDecl);
186+
}
187+
}
188+
});
189+
}
190+
191+
function parseJs(jsText) {
192+
const options = { ecmaVersion: 'latest', locations: true };
193+
try {
194+
return acorn.parse(jsText, { ...options, sourceType: 'script' });
195+
} catch (e) {
196+
try {
197+
return acorn.parse(jsText, { ...options, sourceType: 'module' });
198+
} catch (e2) {
199+
return null;
200+
}
201+
}
202+
}
203+
204+
export function jsPreprocess(jsText) {
205+
if (/\/\/\s*noprotect/.test(jsText)) {
206+
return jsText;
207+
}
208+
209+
const ast = parseJs(jsText);
210+
if (!ast) return jsText;
211+
212+
const shaderNames = collectShaderFunctionNames(ast);
213+
const loops = collectLoopsToProtect(ast, shaderNames);
214+
215+
if (loops.length === 0) return jsText;
216+
217+
injectProtection(loops);
218+
219+
return escodegen.generate(ast);
220+
}

0 commit comments

Comments
 (0)