Skip to content

Playground: exit loops which block the page instead of freezing the tab#3618

Open
Dinesh-3018 wants to merge 1 commit into
microsoft:v2from
Dinesh-3018:playground-loop-protection
Open

Playground: exit loops which block the page instead of freezing the tab#3618
Dinesh-3018 wants to merge 1 commit into
microsoft:v2from
Dinesh-3018:playground-loop-protection

Conversation

@Dinesh-3018

Copy link
Copy Markdown

The Problem

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

while (true) {}

then press Run.

The playground tab freezes indefinitely. The only way to recover is to force-close the tab, losing the current code and any unsaved state. The same issue occurs with for (;;) loops, accidental i-- instead of i++, and other unintentionally non-terminating loops that learners commonly write.

(I'm submitting this as a PR rather than an issue, following the issue template guidance that non-critical reports should be submitted as pull requests. The underlying context is described below.)

The root cause is that the Run button evaluates the emitted JavaScript directly on the browser's main thread (packages/playground/src/sidebar/runtime.ts). While the existing try/catch can handle thrown exceptions, it cannot recover from code that never terminates. This limitation was previously discussed in #2541 ("Add ability to stop a running script"), which concluded that interrupting execution wasn't feasible without ShadowRealms. Related discussion: #3155.

The Fix

This PR adopts the same strategy used by JSBin (loop-protect), CodePen, freeCodeCamp, and the Babel REPL (babel/website#352): instrument loops before evaluation instead of moving execution to a Worker or iframe, which would break DOM access and live console capture.

  • Added insertLoopProtection (packages/playground/src/sidebar/loopProtection.ts), which parses the emitted JavaScript using the existing TypeScript AST and injects:

    if (__tsPlaygroundLoopProtection.hit(id, line)) break;

    at the beginning of every for, while, and do loop body.

    Unbraced loop bodies are wrapped in a block when necessary. Instrumentation is inserted only inside loop bodies, never before the loop itself, ensuring constructs such as if (x) while (y) z(), else branches, and labeled loops remain syntactically valid. No additional newlines are introduced, so runtime error locations continue to match the JavaScript displayed to the user.

  • The runtime guard tracks synchronous execution bursts. When execution begins, it schedules a zero-delay timeout. Every guard check before the event loop regains control belongs to the same burst. If a burst blocks the main thread for more than 1000 ms, all guarded loops terminate. The Logs pane reports the loop that appears to be infinite, while subsequent loops in the same exhausted burst display an "exited early" message instead of also being reported as infinite.

    This approach correctly handles:

    • Nested infinite loops
    • Loops with expensive iterations

    while leaving unaffected:

    • await-based loops
    • setInterval callbacks
    • Normal loops that complete within the time budget
  • Instrumentation degrades safely. Code that cannot be parsed, along with edge cases such as declaration-bodied loops (while (true) let x = 1;) that currently produce a runtime SyntaxError, is left completely unchanged so behavior matches today's playground exactly.

  • Added a Disable Loop Protection setting for intentionally long-running loops. The setting takes effect on the next Run, allowing it to be rendered without the restart-required banner. Documentation and localized copy were added to en/playground.ts and the Playground handbook ("Settings Panel" and "Running Code").

  • Known limitations (shared with existing approaches):

    • Infinite recursion (which naturally results in a stack overflow)
    • Long-running synchronous work that doesn't occur inside loops

    Persistent setInterval callbacks from previous runs (the other half of Playground: Add ability to stop a running script #2541) remain intentionally out of scope.

Testing

  • pnpm --filter=@typescript/playground test

    Added 18 tests covering:

    • Nested loops
    • Unbraced loops
    • Labeled loops
    • for...of over an infinite generator
    • try/finally loop bodies
    • Loops used as single statements
    • A finite loop inside a setTimeout callback queued behind an over-budget script (must not terminate early)
    • An over-budget async loop that yields (must not terminate early)
    • Line-number preservation
    • SyntaxError preservation

    The test suite wraps the guard with a hard deadline that throws if protection fails, preventing Jest/CI from hanging during regressions.

  • pnpm --filter=@typescript/playground build completes successfully.

  • The full pnpm bootstrap build passes.

  • All new and modified files pass the repository's Prettier configuration.

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.
@Dinesh-3018

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

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