diff --git a/packages/playground-handbook/copy/en/Running Code.md b/packages/playground-handbook/copy/en/Running Code.md index 3a637ae8fd18..54c96b5cbfa7 100644 --- a/packages/playground-handbook/copy/en/Running Code.md +++ b/packages/playground-handbook/copy/en/Running Code.md @@ -8,6 +8,7 @@ In the editor toolbar, the one which is not visible because you're reading this - Take the code in the editor and convert it to JS - Remove references to `"reflect-metadata"` if you are using decorators +- Add guards to loops so that code which blocks the page for more than a second exits its loops instead of freezing the tab (can be turned off via "Disable Loop Protection" in Settings) - Run that code within the context of your current browser session - Capture any `console.log`, `.error`, `.warn` and `.debug` calls and show them in the sidebar "Logs" tab. diff --git a/packages/playground-handbook/copy/en/Settings Panel.md b/packages/playground-handbook/copy/en/Settings Panel.md index 7b2117bf5f3c..2eab947ed17f 100644 --- a/packages/playground-handbook/copy/en/Settings Panel.md +++ b/packages/playground-handbook/copy/en/Settings Panel.md @@ -12,6 +12,10 @@ Turns off [Type Acquisition](/play#handbook-5) which means that when importing c When the editor loses focus, or compiler flags change, the Playground will replace the URL in your browser. This doesn't change the behavior of the back button, but it does add history entries in the browser. You can turn off this behavior via this setting, and you can use the 'Share' (or press cmd/ctrl + s) to copy the sharable URL. +**"Disable Loop Protection"** + +When running code, the Playground adds a small guard to every loop so that a loop which blocks the page for over a second (like `while (true) {}`) exits instead of freezing the browser tab. If your code intentionally runs long loops, you can turn the guards off via this setting — it applies the next time you press "Run", no reload needed. + ### Sidebar Tabs You can choose which tabs are available in the Playground sidebar via the toggle boxes under above Playground Options. diff --git a/packages/playground/src/index.ts b/packages/playground/src/index.ts index 5c3528041698..f4da87838a62 100644 --- a/packages/playground/src/index.ts +++ b/packages/playground/src/index.ts @@ -449,7 +449,7 @@ export const setupPlayground = ( const runPlugin = plugins.find(p => p.id === "logs")! activatePlugin(runPlugin, getCurrentPlugin(), sandbox, tabBar, container) - runWithCustomLogs(run, i) + runWithCustomLogs(run, i, sandbox.ts) const isJS = sandbox.config.filetype === "js" ui.flashInfo(i(isJS ? "play_run_js" : "play_run_ts")) diff --git a/packages/playground/src/sidebar/loopProtection.ts b/packages/playground/src/sidebar/loopProtection.ts new file mode 100644 index 000000000000..e1c6ce2b30da --- /dev/null +++ b/packages/playground/src/sidebar/loopProtection.ts @@ -0,0 +1,116 @@ +type TS = typeof import("typescript") + +/** How long user code may synchronously block the page before its loops are exited */ +export const loopProtectionBudgetMS = 1000 + +/** The global the instrumented JS uses to reach the guard, set up before each run */ +export const loopProtectionGlobalName = "__tsPlaygroundLoopProtection" + +/** + * Creates the runtime side of loop protection. A "burst" is a stretch of + * synchronous work which never yields back to the event loop: starting a + * burst arms a zero-delay timeout, and until that timeout gets to run every + * guard check belongs to the same burst. Once a burst has blocked the page + * for longer than the budget, every guarded loop is told to break. + */ +export const createLoopProtection = ( + budgetMS: number, + onLoopExited: (line: number, isFirstInBurst: boolean) => void +) => { + const scheduleEndOfBurst = setTimeout.bind(globalThis) + let inBurst = true + let burstStartedAt = Date.now() + let exitedLoopIDs: number[] = [] + // The first burst is primed here, at creation just before the run's eval, + // rather than on the first guard check: that queues the reset timer ahead of + // any timers the user's main script registers, so their callbacks always + // start a fresh burst. (A callback which itself blows the budget can still + // leak its burst into callbacks that were already queued behind it.) + scheduleEndOfBurst(() => (inBurst = false), 0) + + return { + hit(loopID: number, line: number): boolean { + const now = Date.now() + if (!inBurst) { + inBurst = true + burstStartedAt = now + exitedLoopIDs = [] + scheduleEndOfBurst(() => (inBurst = false), 0) + } + if (now - burstStartedAt <= budgetMS) return false + + if (!exitedLoopIDs.includes(loopID)) { + exitedLoopIDs.push(loopID) + onLoopExited(line, exitedLoopIDs.length === 1) + } + return true + }, + } +} + +/** + * Adds a guard call to the top of every for/while/do loop body, so that code + * like `while (true) {}` breaks out after the time budget instead of freezing + * the browser tab. Guards are only inserted into loop bodies (unbraced bodies + * get wrapped in a block), never in front of the loop itself, which keeps + * `if (x) while (y) z()`, labelled loops etc valid. No newlines are added, so + * runtime error positions still line up with the JS the user can see. + */ +export const insertLoopProtection = (ts: TS, code: string): string => { + try { + const sourceFile = ts.createSourceFile("run.js", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) + // Code which doesn't parse should run (and fail) exactly as it does today + if (((sourceFile as any).parseDiagnostics || []).length > 0) return code + + const inserts: Array<{ pos: number; text: string }> = [] + let loopID = 0 + let hasIllegalLoopBody = false + + const visit = (node: import("typescript").Node) => { + if ( + ts.isWhileStatement(node) || + ts.isDoStatement(node) || + ts.isForStatement(node) || + ts.isForInStatement(node) || + ts.isForOfStatement(node) + ) { + loopID += 1 + const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 + const guard = `if (${loopProtectionGlobalName}.hit(${loopID}, ${line})) break; ` + const body = node.statement + if (ts.isBlock(body)) { + inserts.push({ pos: body.getStart(sourceFile) + 1, text: " " + guard }) + } else if ( + ts.isFunctionDeclaration(body) || + ts.isClassDeclaration(body) || + (ts.isVariableStatement(body) && (body.declarationList.flags & ts.NodeFlags.BlockScoped) !== 0) + ) { + // `while (true) let x = 1` is a SyntaxError at run time which the + // parser tolerates; wrapping it in a block would make it legal, so + // leave the whole file untouched and let it throw like it does today + hasIllegalLoopBody = true + } else { + inserts.push({ pos: body.getStart(sourceFile), text: "{ " + guard }) + inserts.push({ pos: body.getEnd(), text: " }" }) + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + if (hasIllegalLoopBody) return code + + // Apply highest position first so earlier offsets stay valid. The sort is + // stable, and inserts which share a position are all identical closing + // braces, so ties cannot reorder meaningfully. + inserts.sort((a, b) => a.pos - b.pos) + let result = code + for (let index = inserts.length - 1; index >= 0; index--) { + const insert = inserts[index] + result = result.slice(0, insert.pos) + insert.text + result.slice(insert.pos) + } + return result + } catch (error) { + // Loop protection should never be the reason a run doesn't happen + return code + } +} diff --git a/packages/playground/src/sidebar/runtime.ts b/packages/playground/src/sidebar/runtime.ts index a26c9cd54ae5..15f63e8432cf 100644 --- a/packages/playground/src/sidebar/runtime.ts +++ b/packages/playground/src/sidebar/runtime.ts @@ -1,6 +1,12 @@ import { PlaygroundPlugin, PluginFactory } from ".." import { createUI } from "../createUI" import { localize } from "../localizeWithFallback" +import { + createLoopProtection, + insertLoopProtection, + loopProtectionBudgetMS, + loopProtectionGlobalName, +} from "./loopProtection" type LogEntry = { id: string @@ -134,7 +140,7 @@ export const clearLogs = () => { } } -export const runWithCustomLogs = (closure: Promise, i: Function) => { +export const runWithCustomLogs = (closure: Promise, i: Function, tsModule?: typeof import("typescript")) => { const noLogs = document.getElementById("empty-message-container") const logContainer = document.getElementById("log-container")! const logToolsContainer = document.getElementById("log-tools")! @@ -144,10 +150,17 @@ export const runWithCustomLogs = (closure: Promise, i: Function) => { logToolsContainer.style.display = "flex" } + // The instrumentation happens out here so that rewireLoggingToElement's + // scope, which the direct eval below can see, gains no new bindings + const loopProtected = + tsModule && !localStorage.getItem("disable-loop-protection") + ? closure.then(js => insertLoopProtection(tsModule, js)) + : closure + rewireLoggingToElement( () => document.getElementById("log")!, () => document.getElementById("log-container")!, - closure, + loopProtected, true, i ) @@ -173,6 +186,15 @@ function rewireLoggingToElement( bindLoggingFunc(replace, rawConsole, "error", "ERR") replace["clear"] = clearLogs const console = Object.assign({}, rawConsole, replace) + if (!localStorage.getItem("disable-loop-protection")) { + ;(globalThis as any)[loopProtectionGlobalName] = createLoopProtection( + loopProtectionBudgetMS, + (line, isFirstInBurst) => { + const key = isFirstInBurst ? "play_run_js_loop_protection" : "play_run_js_loop_protection_skipped" + console.error(i(key, { line, budget: loopProtectionBudgetMS })) + } + ) + } try { const safeJS = sanitizeJS(js) eval(safeJS) diff --git a/packages/playground/src/sidebar/settings.ts b/packages/playground/src/sidebar/settings.ts index 6e076b9b842b..df9df9439604 100644 --- a/packages/playground/src/sidebar/settings.ts +++ b/packages/playground/src/sidebar/settings.ts @@ -45,6 +45,16 @@ export const settingsPlugin: PluginFactory = (i, utils) => { // }, ] + // Applies on the next Run without a reload, so it stays out of the + // requireRestart list above + const runSettings: LocalStorageOption[] = [ + { + display: i("play_sidebar_options_disable_loop_protection"), + blurb: i("play_sidebar_options_disable_loop_protection_copy"), + flag: "disable-loop-protection", + }, + ] + const uiPlugins: LocalStorageOption[] = [ { display: i("play_sidebar_js_title"), @@ -91,6 +101,7 @@ export const settingsPlugin: PluginFactory = (i, utils) => { ds.subtitle(i("play_subnav_settings")) ds.showOptionList(settings, { style: "separated", requireRestart: true }) + ds.showOptionList(runSettings, { style: "separated" }) ds.subtitle(i("play_settings_tabs_settings")) ds.showOptionList(uiPlugins, { style: "separated", requireRestart: true }) diff --git a/packages/playground/test/loopProtection.test.js b/packages/playground/test/loopProtection.test.js new file mode 100644 index 000000000000..1ecfa59cc235 --- /dev/null +++ b/packages/playground/test/loopProtection.test.js @@ -0,0 +1,185 @@ +const fs = require("fs") +const path = require("path") +const ts = require("typescript") + +// The playground's jest setup has no TypeScript transform, so compile the +// module under test on the fly instead +const loadLoopProtection = () => { + const sourcePath = path.join(__dirname, "..", "src", "sidebar", "loopProtection.ts") + const source = fs.readFileSync(sourcePath, "utf8") + const js = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2018 }, + }).outputText + const moduleExports = {} + new Function("exports", "require", "module", js)(moduleExports, require, { exports: moduleExports }) + return moduleExports +} + +const { insertLoopProtection, createLoopProtection, loopProtectionGlobalName } = loadLoopProtection() + +// Executes instrumented code with the real guard, wrapped in a hard deadline +// which throws: if protection ever regresses, the throw breaks the loop and +// fails the test instead of hanging jest (a synchronous loop would block +// jest's own testTimeout timers forever) +const runInstrumented = (code, budgetMS = 80) => { + const exitedLines = [] + const instrumented = insertLoopProtection(ts, code) + expect(instrumented).toContain(".hit(") + const realGuard = createLoopProtection(budgetMS, line => exitedLines.push(line)) + const startedAt = Date.now() + global[loopProtectionGlobalName] = { + hit: (id, line) => { + if (Date.now() - startedAt > 5000) throw new Error("loop protection failed to break the loop") + return realGuard.hit(id, line) + }, + } + new Function(instrumented)() + return { exitedLines, elapsedMS: Date.now() - startedAt, instrumented } +} + +describe("insertLoopProtection", () => { + it("returns code without loops untouched", () => { + const code = `const a = 1\nconsole.log(a)` + expect(insertLoopProtection(ts, code)).toEqual(code) + }) + + it("returns code which does not parse untouched", () => { + const code = `while (true {` + expect(insertLoopProtection(ts, code)).toEqual(code) + }) + + it("returns declaration-bodied loops untouched so they still throw", () => { + // Wrapping these bodies in a block would silently legalize a SyntaxError + const samples = [ + `while (true) let x = 1;`, + `for (let i = 0; i < 2; i++) const x = i;`, + `while (true) class C {}`, + `while (true) function f() {}`, + ] + for (const code of samples) { + expect(insertLoopProtection(ts, code)).toEqual(code) + } + }) + + it("does not change the number of lines", () => { + const code = `let i = 0\nwhile (true) {\n i++\n}\nfor (;;) i++\n` + const instrumented = insertLoopProtection(ts, code) + expect(instrumented.split("\n").length).toEqual(code.split("\n").length) + }) + + it("reports the loop's one-based line number", () => { + const instrumented = insertLoopProtection(ts, `\n\nwhile (true) {}`) + expect(instrumented).toContain(".hit(1, 3)") + }) +}) + +describe("running instrumented code", () => { + it("exits while (true) {} instead of running forever", () => { + const { exitedLines, elapsedMS } = runInstrumented(`while (true) {}`) + expect(exitedLines).toEqual([1]) + expect(elapsedMS).toBeLessThan(2000) + }) + + it("exits a for loop with an empty statement body", () => { + const { exitedLines } = runInstrumented(`for (;;);`) + expect(exitedLines).toEqual([1]) + }) + + it("exits a do-while with an unbraced body", () => { + const { exitedLines } = runInstrumented(`let i = 0\ndo i++; while (true)`) + expect(exitedLines).toEqual([2]) + }) + + it("exits nested loops, reporting each one once", () => { + const { exitedLines } = runInstrumented(`while (true) { while (true) {} }`) + expect(exitedLines).toContain(1) + expect(exitedLines.length).toBeLessThanOrEqual(2) + }) + + it("keeps a loop in single-statement position valid", () => { + const { exitedLines } = runInstrumented(`let i = 0\nif (i === 0) while (true) i++;`) + expect(exitedLines).toEqual([2]) + }) + + it("keeps unbraced loop-as-loop-body valid", () => { + const { exitedLines } = runInstrumented(`let i = 0\nwhile (true) while (true) i++;`) + expect(exitedLines.length).toBeGreaterThan(0) + }) + + it("exits labelled loops without breaking the label", () => { + const { exitedLines } = runInstrumented(`outer: while (true) { continue outer }`) + expect(exitedLines).toEqual([1]) + }) + + it("exits a for-of over an endless generator", () => { + const code = `function* gen() { while (true) yield 1 }\nlet n = 0\nfor (const v of gen()) n += v` + const { exitedLines } = runInstrumented(code) + expect(exitedLines.length).toBeGreaterThan(0) + }) + + it("leaves loops which finish within the budget alone", () => { + const code = `let n = 0\nfor (let i = 0; i < 1000; i++) n++\nglobalThis.__loopProtectionTestResult = n` + const { exitedLines } = runInstrumented(code, 500) + expect(exitedLines).toEqual([]) + expect(global.__loopProtectionTestResult).toEqual(1000) + delete global.__loopProtectionTestResult + }) + + it("flags only the first exited loop in a burst as possibly infinite", () => { + const reports = [] + const realGuard = createLoopProtection(80, (line, isFirstInBurst) => { + reports.push([line, isFirstInBurst]) + }) + const startedAt = Date.now() + global[loopProtectionGlobalName] = { + hit: (id, line) => { + if (Date.now() - startedAt > 5000) throw new Error("loop protection failed to break the loop") + return realGuard.hit(id, line) + }, + } + const code = `while (true) {}\nlet n = 0\nfor (let i = 0; i < 3; i++) n++` + new Function(insertLoopProtection(ts, code))() + expect(reports).toEqual([ + [1, true], + [3, false], + ]) + }) + + it("does not break finite loops in timer callbacks queued behind an over-budget script", async () => { + const exitedLines = [] + const code = [ + `globalThis.__loopProtectionTestPromise = new Promise(resolve => {`, + ` setTimeout(() => { let n = 0; for (let i = 0; i < 3; i++) n++; resolve(n) }, 0)`, + `})`, + `const end = Date.now() + 250`, + `while (Date.now() < end) {}`, + ].join("\n") + const instrumented = insertLoopProtection(ts, code) + expect(instrumented).toContain(".hit(") + // Created right before execution, matching the runtime's ordering, so the + // burst-reset timer is queued ahead of the user's setTimeout + global[loopProtectionGlobalName] = createLoopProtection(100, line => exitedLines.push(line)) + new Function(instrumented)() + const n = await global.__loopProtectionTestPromise + delete global.__loopProtectionTestPromise + expect(n).toEqual(3) + expect(exitedLines).toEqual([5]) + }) + + it("does not exit loops which yield to the event loop", async () => { + const exitedLines = [] + const code = + `globalThis.__loopProtectionTestPromise = (async () => {` + + ` for (let i = 0; i < 5; i++) { await new Promise(r => setTimeout(r, 30)) }` + + ` globalThis.__loopProtectionTestDone = true })()` + const instrumented = insertLoopProtection(ts, code) + expect(instrumented).toContain(".hit(") + global[loopProtectionGlobalName] = createLoopProtection(50, line => exitedLines.push(line)) + new Function(instrumented)() + await global.__loopProtectionTestPromise + expect(exitedLines).toEqual([]) + expect(global.__loopProtectionTestDone).toEqual(true) + delete global.__loopProtectionTestPromise + delete global.__loopProtectionTestDone + }) +}) diff --git a/packages/typescriptlang-org/src/copy/en/playground.ts b/packages/typescriptlang-org/src/copy/en/playground.ts index 6936d5ea6cf7..dd345dccc859 100644 --- a/packages/typescriptlang-org/src/copy/en/playground.ts +++ b/packages/typescriptlang-org/src/copy/en/playground.ts @@ -26,6 +26,9 @@ export const playCopy = { play_sidebar_options_disable_save: "Disable Save-On-Type", play_sidebar_options_disable_save_copy: "Disable changing the URL when you type.", + play_sidebar_options_disable_loop_protection: "Disable Loop Protection", + play_sidebar_options_disable_loop_protection_copy: + "Stop the Playground from automatically exiting loops which block the page for too long when running code.", play_sidebar_plugins: "Plugins", play_sidebar_featured_plugins: "Featured Plugins", play_sidebar_plugins_options_external: @@ -55,6 +58,10 @@ export const playCopy = { play_run_js: "Executed JavaScript", play_run_ts: "Executed transpiled TypeScript", play_run_js_fail: "Executed JavaScript Failed:", + play_run_js_loop_protection: + "Possible infinite loop detected around line {line}. The loop was exited after the code blocked the page for {budget}ms. You can turn this off via Disable Loop Protection in Settings.", + play_run_js_loop_protection_skipped: + "The loop around line {line} was exited early because the page had already been blocked for {budget}ms.", play_default_code_sample: `// Welcome to the TypeScript Playground, this is a website // which gives you a chance to write, share and learn TypeScript.