Skip to content

Centralize loop guard injection into AST transformation#399

Open
tracygardner wants to merge 1 commit intomainfrom
claude/acorn-loop-generation-M10Hf
Open

Centralize loop guard injection into AST transformation#399
tracygardner wants to merge 1 commit intomainfrom
claude/acorn-loop-generation-M10Hf

Conversation

@tracygardner
Copy link
Contributor

@tracygardner tracygardner commented Mar 15, 2026

Summary

Refactored loop guard injection from being hardcoded in individual code generators into a centralized AST transformation pass. This improves maintainability and ensures consistent loop yielding behavior across all loop types.

Key Changes

  • Added injectLoopGuards() method in flock.js that:

    • Parses user code into an AST using acorn
    • Identifies all loop statements (while, do-while, for, for-in, for-of)
    • Injects await wait(0); guards into loop bodies that don't already contain await expressions
    • Applies insertions in reverse order to maintain position validity
  • Updated runCode() method to call injectLoopGuards() on user code before execution, passing the instrumented code to the SES compartment

  • Removed hardcoded loop guards from generators/generators.js:

    • Removed await wait(0); from while loop generator
    • Removed await wait(0); from do-while loop generator
    • Removed await wait(0); from repeat/for loop generator
    • Removed await wait(0); from forEach loop generator

Implementation Details

  • The bodyHasAwait() helper uses AST walking to detect if a loop body already contains await expressions, avoiding redundant guards
  • Loop bodies without await expressions receive guards to ensure the event loop can process other tasks
  • The transformation is applied at runtime before code execution, making it transparent to the block-based code generators

https://claude.ai/code/session_01Dvex7RparGEMpvAAR2iF1H

Summary by CodeRabbit

  • New Features

    • Added intelligent asynchronous yielding safeguards in JavaScript loops to prevent execution blocking and maintain responsiveness.
  • Refactor

    • Optimized loop guard injection mechanism with centralized instrumentation for improved efficiency and reliability.

…enerators

Add `injectLoopGuards(src)` to the flock object, which uses acorn/acorn-walk to
parse generated code and automatically insert `await wait(0)` before the closing
brace of any loop body that doesn't already contain an await expression. This
centralises the loop-guard concern so individual generators no longer need to
embed it inline.

Remove the explicit `await wait(0)` from controls_whileUntil, controls_doWhile,
controls_repeat_ext, and controls_forEach. The more sophisticated
timing/RAF-based guards in controls_for and for_loop are preserved; because
those bodies already contain an AwaitExpression, injectLoopGuards skips them
automatically.

The injection runs in runCode() after AST validation, so it also protects any
hand-written user code that contains unguarded loops.

https://claude.ai/code/session_01Dvex7RparGEMpvAAR2iF1H
@coderabbitai
Copy link

coderabbitai bot commented Mar 15, 2026

📝 Walkthrough

Walkthrough

The PR refactors loop delay injection by introducing a centralized injectLoopGuards() method in flock.js that automatically instruments user code at parse time, replacing manual guard insertion in individual Blockly code generators.

Changes

Cohort / File(s) Summary
Loop guard instrumentation
flock.js
Added new public method injectLoopGuards(src) that parses JavaScript with Acorn, detects loop bodies (while, do-while, for, for-in, for-of), and inserts await wait(0); guards in loops lacking AwaitExpression. Integrated into runCode() to instrument code before execution.
Generator cleanup
generators/generators.js
Removed explicit await wait(0); insertions from four Blockly loop generator blocks (controls_whileUntil, controls_doWhile, controls_repeat_ext, controls_forEach), consolidating guard injection to flock.js.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Loops now guard themselves with care,
No more scattered waits here and there!
Parse, inject, and execute with grace,
Acorn helps us find the right place. 🌰

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Centralize loop guard injection into AST transformation' accurately describes the main change: moving loop guard injection from individual generators into a centralized AST transformation via the new injectLoopGuards() method.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/acorn-loop-generation-M10Hf
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@flock.js`:
- Around line 666-692: The loop instrumentation currently inserts "await
wait(0)" everywhere (in walk.simple handlers for WhileStatement,
DoWhileStatement, ForStatement, ForInStatement, ForOfStatement), which yields
invalid syntax inside non-async scopes; update the AST traversal to track
async-capable ancestry (push/pop a flag when entering FunctionDeclaration,
FunctionExpression, ArrowFunctionExpression, MethodDefinition, and
GeneratorFunction nodes and consider top-level/module await rules) and only call
addGuardIfNeeded when the current ancestry indicates await is legal (i.e.,
inside an async function or allowed top-level); keep the insertion logic
(insertions sort + splice into result) unchanged but ensure you reference the
existing addGuardIfNeeded and the walk/simple handlers so guards are only added
where await is syntactically valid.
- Around line 689-691: The injected guard currently calls the bare identifier
wait which can be shadowed by user code; change the injected call to a reserved
helper name like __flockLoopGuardWait__ (replace the "await wait(0);" injection
with "await __flockLoopGuardWait__(0);"), export that identifier from
createWhitelist() so it is available to the runtime, and add
__flockLoopGuardWait__ to the REJECT_IDENTIFIERS set checked by
validateUserCodeAST() so user code cannot define or shadow it.
- Around line 642-663: The loop-guardging logic must (1) normalize
single-statement loop bodies into BlockStatement so constructs like while(true)
foo(); get guarded, (2) inject the guard as the first statement of the block
(not just before the closing brace) so a continue still executes the guard on
the next iteration, and (3) make bodyHasAwait ignore awaits inside nested
function declarations/expressions/arrow functions so an inner async function
containing await does not cause skipping; to implement: update addGuardIfNeeded
to wrap non-BlockStatement loop bodies in a BlockStatement (preserving the
original node) and record insertion positions at the block start (or better,
record the AST block node to insert the guard as its first statement instead of
using end-1), and change bodyHasAwait to use walk.simple but override/skipping
handlers for FunctionDeclaration/FunctionExpression/ArrowFunctionExpression (and
similar) so traversal does not descend into nested functions when detecting
AwaitExpression.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dc0ef1b7-f320-4d4a-b7be-cde880f57f6d

📥 Commits

Reviewing files that changed from the base of the PR and between 4550228 and 60ca5b1.

📒 Files selected for processing (2)
  • flock.js
  • generators/generators.js
💤 Files with no reviewable changes (1)
  • generators/generators.js

Comment on lines +642 to +663
// Check whether a BlockStatement already contains any await expression
// (at any nesting depth). Loops that already yield don't need an extra guard.
function bodyHasAwait(node) {
let found = false;
walk.simple(node, {
AwaitExpression() {
found = true;
},
});
return found;
}

// Collect positions (just before the closing '}') where we'll inject
const insertions = [];

function addGuardIfNeeded(loopBody) {
if (
loopBody?.type === "BlockStatement" &&
!bodyHasAwait(loopBody)
) {
insertions.push(loopBody.end - 1);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's explore the repository structure and locate the file
git ls-files | grep -E "flock\.js|\.js$" | head -20

Repository: flipcomputing/flock

Length of output: 354


🏁 Script executed:

# Read the full context of flock.js around the lines mentioned
wc -l flock.js

Repository: flipcomputing/flock

Length of output: 76


🏁 Script executed:

# Get the context around lines 642-663 and 684-692
sed -n '620,710p' flock.js | cat -n

Repository: flipcomputing/flock

Length of output: 4632


🏁 Script executed:

# Search for walk.simple usage to understand its behavior
rg "walk\." flock.js -A 3 -B 3 | head -100

Repository: flipcomputing/flock

Length of output: 912


🏁 Script executed:

# Look for any test files or documentation about this feature
fd -e test -e spec -e example flock | head -20

Repository: flipcomputing/flock

Length of output: 45


🏁 Script executed:

# Search for continue statements in test context and loop examples
rg "continue" flock.js -B 3 -A 3

Repository: flipcomputing/flock

Length of output: 45


🏁 Script executed:

# Look for async function examples within loops in the codebase
rg "while|for.*{" flock.js -A 10 | grep -E "(while|for|async|await)" | head -30

Repository: flipcomputing/flock

Length of output: 150


🏁 Script executed:

# Let's see the broader context around the problematic lines and the second location
sed -n '630,700p' flock.js | cat -n

Repository: flipcomputing/flock

Length of output: 3397


🏁 Script executed:

# Search for imports to understand which walk library is being used
head -50 flock.js | grep -E "import|require|walk"

Repository: flipcomputing/flock

Length of output: 1256


🏁 Script executed:

# Look for any comments or documentation about loop guards
rg "guard|yield|busy" flock.js -B 2 -A 2 | head -50

Repository: flipcomputing/flock

Length of output: 2029


🏁 Script executed:

# Search for examples of loops with await to understand the pattern
rg "await" flock.js -B 5 -A 5 | head -80

Repository: flipcomputing/flock

Length of output: 3561


🏁 Script executed:

# Check the second location mentioned (684-692)
sed -n '675,700p' flock.js | cat -n

Repository: flipcomputing/flock

Length of output: 1266


🏁 Script executed:

# Let's verify the behavior of acorn-walk's simple function with a quick test
python3 << 'EOF'
# Let's reason through the acorn-walk behavior:
# walk.simple() is a recursive visitor that traverses the entire AST

# Test case 1: continue statement
# Code: while(true) { x++; continue; }
# Guard inserted at loopBody.end - 1 (before closing })
# Result: while(true) { x++; continue; await wait(0); }
# But continue jumps to next iteration, skipping the guard
# TRUE: continue escapes the guard

# Test case 2: single-statement
# Code: while(true) foo();
# loopBody.type would be ExpressionStatement, NOT BlockStatement
# addGuardIfNeeded checks: loopBody?.type === "BlockStatement"
# No guard is added
# TRUE: single-statement bodies not guarded

# Test case 3: nested async
# Code: while(true) { async function f() { await x; } }
# walk.simple traverses entire tree, finds AwaitExpression inside async function
# bodyHasAwait returns true, so NO guard is added
# But the loop itself doesn't yield - only the function yields if called
# If f() is never called, loop blocks UI
# TRUE: nested async awaits incorrectly treated as loop yields

print("All three escape conditions appear to be valid based on code analysis")
EOF

Repository: flipcomputing/flock

Length of output: 132


🏁 Script executed:

# Let's also check if there's a "wait" function defined to understand the guard
rg "function wait|const wait|=.*wait.*=" flock.js | head -10

Repository: flipcomputing/flock

Length of output: 45


🏁 Script executed:

# Check for any PR context or comments about this feature
git log --oneline -20 2>/dev/null | head -10

Repository: flipcomputing/flock

Length of output: 149


Loop guards can be bypassed in three ways, reintroducing UI-blocking loops.

The current implementation has three bypass mechanisms that defeat the guard:

  1. continue statements skip the tail-inserted guard — Since the guard is injected just before the closing }, a continue jumps to the next iteration without executing it.

  2. Single-statement loop bodies are never guarded — The code checks loopBody?.type === "BlockStatement", so loops like while (true) foo(); are never instrumented.

  3. bodyHasAwait() treats awaits in nested async functions as loop yields — Since walk.simple() traverses the entire AST, an async function() { await x; } inside the loop body causes the guard to be skipped, even though the loop itself doesn't yield if the function is never called.

All three reintroduce the "busy loop blocks the UI" failure mode. Normalizing eligible loop bodies to blocks and injecting the guard as the first statement of each iteration would close these gaps.

Also applies to: 684-692

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flock.js` around lines 642 - 663, The loop-guardging logic must (1) normalize
single-statement loop bodies into BlockStatement so constructs like while(true)
foo(); get guarded, (2) inject the guard as the first statement of the block
(not just before the closing brace) so a continue still executes the guard on
the next iteration, and (3) make bodyHasAwait ignore awaits inside nested
function declarations/expressions/arrow functions so an inner async function
containing await does not cause skipping; to implement: update addGuardIfNeeded
to wrap non-BlockStatement loop bodies in a BlockStatement (preserving the
original node) and record insertion positions at the block start (or better,
record the AST block node to insert the guard as its first statement instead of
using end-1), and change bodyHasAwait to use walk.simple but override/skipping
handlers for FunctionDeclaration/FunctionExpression/ArrowFunctionExpression (and
similar) so traversal does not descend into nested functions when detecting
AwaitExpression.

Comment on lines +666 to +692
walk.simple(ast, {
WhileStatement(node) {
addGuardIfNeeded(node.body);
},
DoWhileStatement(node) {
addGuardIfNeeded(node.body);
},
ForStatement(node) {
addGuardIfNeeded(node.body);
},
ForInStatement(node) {
addGuardIfNeeded(node.body);
},
ForOfStatement(node) {
addGuardIfNeeded(node.body);
},
});

// Apply from last to first so earlier positions stay valid
insertions.sort((a, b) => b - a);

let result = src;
for (const pos of insertions) {
result =
result.slice(0, pos) +
"await wait(0);\n" +
result.slice(pos);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In JavaScript, is await syntactically valid inside a non-async function body when the surrounding program is executed from an async IIFE?

💡 Result:

No. await is not syntactically valid inside the body of a non-async function, even if that function is called from (or nested inside) an async IIFE—the code will fail at parse time with a syntax error. await is only allowed inside async functions (and async generators), or at the top level of an ES module (“top-level await”). [1] [2]


Only inject guards inside async-capable scopes.

This code instruments loops anywhere in the AST, producing invalid JavaScript. For example, function tick() { while (true) {} } becomes syntactically invalid after transformation because await is not legal inside non-async function bodies—the code fails at parse time. The wrapper async IIFE does not change the syntax rules for nested functions, methods, or generators. Track async ancestry before inserting guards to ensure guards are only injected where await is syntactically valid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flock.js` around lines 666 - 692, The loop instrumentation currently inserts
"await wait(0)" everywhere (in walk.simple handlers for WhileStatement,
DoWhileStatement, ForStatement, ForInStatement, ForOfStatement), which yields
invalid syntax inside non-async scopes; update the AST traversal to track
async-capable ancestry (push/pop a flag when entering FunctionDeclaration,
FunctionExpression, ArrowFunctionExpression, MethodDefinition, and
GeneratorFunction nodes and consider top-level/module await rules) and only call
addGuardIfNeeded when the current ancestry indicates await is legal (i.e.,
inside an async function or allowed top-level); keep the insertion logic
(insertions sort + splice into result) unchanged but ensure you reference the
existing addGuardIfNeeded and the walk/simple handlers so guards are only added
where await is syntactically valid.

Comment on lines +689 to +691
result =
result.slice(0, pos) +
"await wait(0);\n" +
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | head -20

Repository: flipcomputing/flock

Length of output: 437


🏁 Script executed:

fd -t f "flock.js" | head -5

Repository: flipcomputing/flock

Length of output: 71


🏁 Script executed:

wc -l flock.js

Repository: flipcomputing/flock

Length of output: 76


🏁 Script executed:

sed -n '680,700p' flock.js

Repository: flipcomputing/flock

Length of output: 875


🏁 Script executed:

rg "wait" flock.js -A 2 -B 2 | head -50

Repository: flipcomputing/flock

Length of output: 2176


🏁 Script executed:

rg "createWhitelist\|wait" flock.js -B 3 -A 5 | head -100

Repository: flipcomputing/flock

Length of output: 45


🏁 Script executed:

rg "function wait\|const wait\|=.*wait" flock.js | head -20

Repository: flipcomputing/flock

Length of output: 45


🏁 Script executed:

rg "wait" flock.js -B 5 -A 5 | grep -A 10 -B 10 "await wait"

Repository: flipcomputing/flock

Length of output: 756


🏁 Script executed:

rg "function createWhitelist|createWhitelist" flock.js -B 2 -A 20

Repository: flipcomputing/flock

Length of output: 2343


🏁 Script executed:

sed -n '900,1100p' flock.js | grep -A 50 "createWhitelist"

Repository: flipcomputing/flock

Length of output: 2747


🏁 Script executed:

rg "return.*\{" flock.js -A 30 | grep -B 10 -A 30 "wait"

Repository: flipcomputing/flock

Length of output: 2170


🏁 Script executed:

sed -n '900,1100p' flock.js

Repository: flipcomputing/flock

Length of output: 11739


🏁 Script executed:

sed -n '1000,1200p' flock.js

Repository: flipcomputing/flock

Length of output: 11072


🏁 Script executed:

rg "validateUserCodeAST" flock.js -B 3 -A 30

Repository: flipcomputing/flock

Length of output: 2926


🏁 Script executed:

sed -n '500,700p' flock.js | grep -A 100 "REJECT_IDENTIFIERS"

Repository: flipcomputing/flock

Length of output: 7628


🏁 Script executed:

rg "REJECT_IDENTIFIERS" flock.js -A 100 | head -150

Repository: flipcomputing/flock

Length of output: 6659


Use a hygienic helper name for the injected guard.

The injected code calls bare wait, which is exposed as a public API in the whitelist. User code can shadow this identifier (const wait = 0, function wait() {}), causing the guard to malfunction at runtime. Route this through a reserved name like __flockLoopGuardWait__ that is both exposed from createWhitelist() and rejected in validateUserCodeAST() so user code cannot redefine it.

Suggested fix
-                                "await wait(0);\n" +
+                                "await __flockLoopGuardWait__(0);\n" +

Then expose __flockLoopGuardWait__ from createWhitelist() and add it to the REJECT_IDENTIFIERS set in validateUserCodeAST().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flock.js` around lines 689 - 691, The injected guard currently calls the bare
identifier wait which can be shadowed by user code; change the injected call to
a reserved helper name like __flockLoopGuardWait__ (replace the "await wait(0);"
injection with "await __flockLoopGuardWait__(0);"), export that identifier from
createWhitelist() so it is available to the runtime, and add
__flockLoopGuardWait__ to the REJECT_IDENTIFIERS set checked by
validateUserCodeAST() so user code cannot define or shadow it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants