Skip to content

Playground: exit loops which block the page instead of freezing the tab when running code - #3617

Closed
dinesh3018 wants to merge 1 commit into
microsoft:v2from
dinesh3018:playground-loop-protection
Closed

Playground: exit loops which block the page instead of freezing the tab when running code#3617
dinesh3018 wants to merge 1 commit into
microsoft:v2from
dinesh3018:playground-loop-protection

Conversation

@dinesh3018

Copy link
Copy Markdown

The problem

Open https://www.typescriptlang.org/play, type:

while (true) {}

and press Run. The tab freezes permanently — the only way out is killing the tab, losing the code and any unsaved state. Same story for for(;;), accidental i-- instead of i++, and every other runaway loop a learner writes. (Filing this as a PR rather than an issue per the issue-template guidance that non-critical reports should come as PRs; the underlying bug context is below.)

The cause is that the Run button evals the emitted JS directly on the page's main thread (packages/playground/src/sidebar/runtime.ts) — the try/catch there can handle a thrown error, but nothing can interrupt non-termination, which is what was concluded in #2541 ("Add ability to stop a running script", closed as not feasible without ShadowRealms). Related Run-UX discussion: #3155.

The fix

The same approach JSBin (loop-protect), CodePen, freeCodeCamp and the Babel REPL (babel/website#352) converged on for main-thread-eval playgrounds — instrument loops before eval, since a Worker/iframe runner would break DOM access and the live console capture:

  • A new insertLoopProtection (in packages/playground/src/sidebar/loopProtection.ts) parses the emitted JS with the already-loaded TypeScript AST and inserts if (__tsPlaygroundLoopProtection.hit(id, line)) break; at the top of every for/while/do loop body. Unbraced bodies are wrapped in a block; nothing is ever inserted before a loop, so if (x) while (y) z(), else-bodies and labelled loops stay valid. No newlines are added, so runtime error positions still match the JS the user can see.
  • The runtime guard tracks a "synchronous burst": starting a burst arms a zero-delay timeout, and until the event loop gets a turn every guard check belongs to that burst. Once a burst has blocked the page for over 1000ms, every guarded loop exits and the Logs pane reports which line looked infinite (subsequent loops in the same exhausted burst get distinct "exited early" copy rather than being blamed as infinite). This also catches nested infinite loops and loops whose individual iterations are slow, while never touching await-loops, setInterval callbacks, or ordinary sub-second loops.
  • Guards degrade safely: code that doesn't parse, and edge cases like declaration-bodied loops (while (true) let x = 1; — a runtime SyntaxError which wrapping would legalize) are left byte-for-byte untouched so they behave exactly as today.
  • A "Disable Loop Protection" setting (applies on the next Run, so it's rendered without the restart-required banner) is there for intentionally long-running loops, with copy in en/playground.ts and entries in the playground handbook ("Settings Panel" and "Running Code").
  • Known blind spots, same as all the prior art: infinite recursion (stack-overflows on its own) and long non-loop synchronous work. Stray setIntervals from earlier runs (Playground: Add ability to stop a running script #2541's other half) are intentionally out of scope.

Testing

  • pnpm --filter=@typescript/playground test — 18 tests, including: nested/unbraced/labelled loops, for-of over an endless generator, try/finally bodies, loops in single-statement position, a finite loop in a setTimeout callback queued behind an over-budget script (must not be broken), an over-budget async loop that yields (must not be broken), and line-number/SyntaxError-preservation checks. The tests wrap the guard in a hard deadline that throws, so a protection regression fails the suite instead of hanging jest/CI.
  • pnpm --filter=@typescript/playground build (tsc) is clean, the full pnpm bootstrap build passes, and new/changed files pass the repo prettier config.

🤖 Generated with Claude Code

Running code containing an infinite loop (e.g. `while (true) {}`) freezes
the browser tab, because the Run button evals the emitted JS directly on
the main thread with no way to stop it (microsoft#2541).

This adds loop protection in the same style as JSBin, CodePen and the
Babel REPL: before eval, a guard is inserted at the top of every
for/while/do loop body using the TypeScript AST. If user code blocks the
page for more than a second, every guarded loop exits and the Logs pane
shows which line looked infinite. Await-loops, timers and ordinary
sub-second loops are never affected, no newlines are added so error line
numbers still match, and a "Disable Loop Protection" setting turns it
off for intentionally long-running code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dinesh3018

Copy link
Copy Markdown
Author

Closing — this was opened from the wrong account by mistake. Apologies for the noise.

@dinesh3018 dinesh3018 closed this Jul 26, 2026
@dinesh3018
dinesh3018 deleted the playground-loop-protection branch July 26, 2026 13:38
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.

1 participant