Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/playground-handbook/copy/en/Running Code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions packages/playground-handbook/copy/en/Settings Panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>cmd/ctrl</kbd> + <kbd>s</kbd>) 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.
2 changes: 1 addition & 1 deletion packages/playground/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
116 changes: 116 additions & 0 deletions packages/playground/src/sidebar/loopProtection.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
26 changes: 24 additions & 2 deletions packages/playground/src/sidebar/runtime.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -134,7 +140,7 @@ export const clearLogs = () => {
}
}

export const runWithCustomLogs = (closure: Promise<string>, i: Function) => {
export const runWithCustomLogs = (closure: Promise<string>, 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")!
Expand All @@ -144,10 +150,17 @@ export const runWithCustomLogs = (closure: Promise<string>, 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
)
Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions packages/playground/src/sidebar/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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 })
Expand Down
Loading