-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fix loop protection breaking shader strings and p5.strands functions #4083
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
6273dca
78eee42
cc3ef43
a0b8d98
a1872ad
e967dfc
47b6bf2
bb853d3
7cc69ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||||||||||
| 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) => { | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice, looks good!
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there anything else(i.e
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||
| }); | ||||||||||
|
|
||||||||||
| return loops; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export function jsPreprocess(jsText) { | ||||||||||
| if (/\/\/\s*noprotect/.test(jsText)) { | ||||||||||
| return jsText; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| let ast; | ||||||||||
| try { | ||||||||||
| ast = acorn.parse(jsText, { | ||||||||||
| ecmaVersion: 'latest', | ||||||||||
| sourceType: 'script', | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe worth considering that users could use
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thank you for catching this! added a fallback that tries |
||||||||||
| 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; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: p5.js-web-editor/client/modules/Preview/EmbedFrame.jsx Lines 66 to 69 in 37bc396
...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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||
| 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; | ||||||||||
| } | ||||||||||
| 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'); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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
MemberExpressioncalling a property calledmodifythat would capture all that, but may have some false positives if users have a method calledmodifyin 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!Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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 justbase*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.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.