-
Notifications
You must be signed in to change notification settings - Fork 4.7k
inspector: interrupt a busy JS loop so Debugger.pause is serviced #32549
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
robobun
wants to merge
10
commits into
main
Choose a base branch
from
farm/173a725c/debugger-pause-busy-loop
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
4962906
inspector: interrupt a busy JS loop so Debugger.pause is serviced
robobun 9bd126c
[autofix.ci] apply automated fixes
autofix-ci[bot] f301787
inspector: fix cross-thread race in pause guard; drop test timeout
robobun 5f01130
[autofix.ci] apply automated fixes
autofix-ci[bot] 4a54609
test(inspector): gate busy-loop pause repro to ASAN builds
robobun 21d037c
test(inspector): gate busy-loop pause repro to debug builds
robobun 88fd5e2
ci: retrigger
robobun dd81bce
test(inspector): run busy-loop pause repro on ASAN lanes
robobun 988c34e
inspector: arm VM trap on resume so a raced-in pause is serviced
robobun 6dc2ef0
test(inspector): drop redundant manual cleanup alongside await using
robobun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| // https://github.com/oven-sh/bun/issues/32548 | ||
|
|
||
| import { expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe, isASAN, tempDir } from "harness"; | ||
|
|
||
| // This regression is a timing race: the busy loop must monopolize the JS thread | ||
| // before the queued Debugger.pause is dispatched. On optimized non-ASAN release | ||
| // builds the pause routinely wins the race and fires even without the fix, so a | ||
| // fail-before there is flaky and the test would be an unreliable regression | ||
| // guard. ASAN builds (local `bun bd` and the release-asan CI lanes) run slowly | ||
| // enough to reproduce it deterministically, so the test gates on isASAN. Those | ||
| // lanes also need the test/no-validate-exceptions.txt entry to dodge a | ||
| // pre-existing unchecked exception in WebKit's JSJavaScriptCallFrame::scopeChain | ||
| // (a plain `debugger;` pause hits it too; unrelated to this fix). The fix itself | ||
| // is platform independent. | ||
| test.skipIf(!isASAN)("Debugger.pause interrupts a busy loop and reports call frames", async () => { | ||
| using dir = tempDir("issue-32548", { | ||
| "index.js": ` | ||
| let counter = 0; | ||
| console.log("busy-ready"); | ||
| while (true) { | ||
| counter++; | ||
| if (counter === Number.MAX_SAFE_INTEGER) console.log(counter); | ||
| } | ||
| `, | ||
| }); | ||
|
|
||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "--inspect-wait=ws://127.0.0.1:0/bun32548", "index.js"], | ||
| env: bunEnv, | ||
| cwd: String(dir), | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
|
|
||
| // Parse the inspector URL from the banner on stderr, and separately watch | ||
| // stdout for "busy-ready" so we know the loop is actually running before we | ||
| // ask the debugger to pause. | ||
| let stderrBuf = ""; | ||
| let stderrLineBuf = ""; | ||
| const { promise: urlPromise, resolve: urlResolve, reject: urlReject } = Promise.withResolvers<URL>(); | ||
| let urlFound = false; | ||
| (async () => { | ||
| const decoder = new TextDecoder(); | ||
| for await (const chunk of proc.stderr as ReadableStream<Uint8Array>) { | ||
| const text = decoder.decode(chunk); | ||
| stderrBuf += text; | ||
| if (!urlFound) { | ||
| stderrLineBuf += text; | ||
| const lines = stderrLineBuf.split("\n"); | ||
| stderrLineBuf = lines.pop() ?? ""; | ||
| for (const line of lines) { | ||
| const trimmed = line.trim(); | ||
| if (!trimmed) continue; | ||
| try { | ||
| const u = new URL(trimmed); | ||
| if (u.protocol === "ws:" || u.protocol === "wss:") { | ||
| urlFound = true; | ||
| urlResolve(u); | ||
| break; | ||
| } | ||
| } catch {} | ||
| } | ||
| } | ||
| } | ||
| if (!urlFound) { | ||
| urlReject(new Error(`Inspector URL not found before child stderr closed: ${JSON.stringify(stderrBuf)}`)); | ||
| } | ||
| })().catch(err => { | ||
| if (!urlFound) urlReject(err); | ||
| }); | ||
|
|
||
| let stdoutBuf = ""; | ||
| let busyReady = false; | ||
| const { promise: busyPromise, resolve: busyResolve, reject: busyReject } = Promise.withResolvers<void>(); | ||
| // Sink: busyPromise is only awaited on the happy path; if an earlier await | ||
| // throws first, its rejection (child exited before "busy-ready") must not | ||
| // surface as an unhandled rejection on top of the real failure. | ||
| busyPromise.catch(() => {}); | ||
| (async () => { | ||
| const decoder = new TextDecoder(); | ||
| for await (const chunk of proc.stdout as ReadableStream<Uint8Array>) { | ||
| stdoutBuf += decoder.decode(chunk); | ||
| if (!busyReady && stdoutBuf.includes("busy-ready")) { | ||
| busyReady = true; | ||
| busyResolve(); | ||
| } | ||
| } | ||
| if (!busyReady) { | ||
| busyReject(new Error(`child stdout closed before "busy-ready": ${JSON.stringify(stdoutBuf)}`)); | ||
| } | ||
| })().catch(err => { | ||
| if (!busyReady) busyReject(err); | ||
| }); | ||
|
robobun marked this conversation as resolved.
|
||
|
|
||
| const url = await urlPromise; | ||
|
|
||
| const ws = new WebSocket(url); | ||
| try { | ||
| await new Promise<void>((resolve, reject) => { | ||
| ws.addEventListener("open", () => resolve(), { once: true }); | ||
| ws.addEventListener("error", e => reject(new Error("WebSocket error", { cause: e })), { once: true }); | ||
| ws.addEventListener("close", e => reject(new Error("WebSocket closed", { cause: e })), { once: true }); | ||
| }); | ||
|
|
||
| let nextId = 1; | ||
| type Waiter = { resolve: (value: any) => void; reject: (error: Error) => void }; | ||
| const pending = new Map<number, Waiter>(); | ||
| const eventWaiters = new Map<string, Waiter>(); | ||
| let closeError: Error | undefined; | ||
|
|
||
| const failAll = (error: Error) => { | ||
| if (closeError) return; | ||
| closeError = error; | ||
| for (const w of pending.values()) w.reject(error); | ||
| pending.clear(); | ||
| for (const w of eventWaiters.values()) w.reject(error); | ||
| eventWaiters.clear(); | ||
| }; | ||
| ws.addEventListener("error", e => failAll(new Error("WebSocket error", { cause: e }))); | ||
| ws.addEventListener("close", e => failAll(new Error(`WebSocket closed (${e.code})`, { cause: e }))); | ||
|
|
||
| ws.addEventListener("message", ev => { | ||
| const msg = JSON.parse(String(ev.data)); | ||
| if (typeof msg.id === "number") { | ||
| const w = pending.get(msg.id); | ||
| if (w) { | ||
| pending.delete(msg.id); | ||
| w.resolve(msg); | ||
| } | ||
| } else if (typeof msg.method === "string") { | ||
| const w = eventWaiters.get(msg.method); | ||
| if (w) { | ||
| eventWaiters.delete(msg.method); | ||
| w.resolve(msg.params); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| const send = (method: string, params: Record<string, unknown> = {}) => | ||
| new Promise<any>((resolve, reject) => { | ||
| if (closeError) return reject(closeError); | ||
| const id = nextId++; | ||
| pending.set(id, { resolve, reject }); | ||
| ws.send(JSON.stringify({ id, method, params })); | ||
| }); | ||
|
|
||
| const waitForEvent = (method: string) => | ||
| new Promise<any>((resolve, reject) => { | ||
| if (closeError) return reject(closeError); | ||
| eventWaiters.set(method, { resolve, reject }); | ||
| }); | ||
|
|
||
| // Attach before any user code runs so the busy loop is compiled with | ||
| // debug hooks (setBreakpointsActive / setPauseOnDebuggerStatements force | ||
| // op_debug insertion), then release --inspect-wait so the loop starts. | ||
| await Promise.all([ | ||
| send("Inspector.enable"), | ||
| send("Runtime.enable"), | ||
| send("Debugger.enable"), | ||
| send("Debugger.setBreakpointsActive", { active: true }), | ||
| send("Debugger.setPauseOnDebuggerStatements", { enabled: true }), | ||
| ]); | ||
|
|
||
| const pausedPromise = waitForEvent("Debugger.paused"); | ||
| // Sink: consumed via Promise.race below only if busyPromise resolves; if an | ||
| // earlier await throws, failAll() rejects this waiter on WS close, which must | ||
| // not become an unhandled rejection. | ||
| pausedPromise.catch(() => {}); | ||
| send("Inspector.initialized").catch(() => {}); | ||
|
|
||
| // Only ask to pause once the loop is provably running. With the bug the | ||
| // pause command is never even dispatched, so don't block on its response; | ||
| // the Debugger.paused event below is the signal that matters. | ||
| await busyPromise; | ||
| send("Debugger.pause").catch(() => {}); | ||
|
|
||
| // With the bug, no Debugger.paused event ever arrives. Bound the wait so | ||
| // the failure is a clear assertion, and clear the timer either way so no | ||
| // stray timer/rejection outlives the test. | ||
| let pauseTimer: ReturnType<typeof setTimeout> | undefined; | ||
| const paused = await Promise.race([ | ||
| pausedPromise, | ||
| new Promise<never>((_, reject) => { | ||
| pauseTimer = setTimeout( | ||
| () => | ||
| reject( | ||
| new Error("Debugger.pause produced no Debugger.paused event within 4s (busy loop was never interrupted)"), | ||
| ), | ||
| 4000, | ||
| ); | ||
| }), | ||
| ]).finally(() => clearTimeout(pauseTimer)); | ||
|
|
||
| expect(Array.isArray(paused.callFrames)).toBe(true); | ||
| expect(paused.callFrames.length).toBeGreaterThan(0); | ||
| const top = paused.callFrames[0]; | ||
| expect(typeof top.functionName).toBe("string"); | ||
| expect(typeof top.location?.scriptId).toBe("string"); | ||
| expect(typeof top.location?.lineNumber).toBe("number"); | ||
| } catch (err) { | ||
| const exitCode = proc.exitCode ?? proc.signalCode ?? "(running)"; | ||
| throw new Error( | ||
| `${err instanceof Error ? err.message : String(err)}\n` + | ||
| ` child exit: ${exitCode}\n` + | ||
| ` child stdout: ${JSON.stringify(stdoutBuf)}\n` + | ||
| ` child stderr: ${JSON.stringify(stderrBuf)}`, | ||
| { cause: err }, | ||
| ); | ||
| } finally { | ||
| try { | ||
| ws.close(); | ||
| } catch {} | ||
| // `await using proc` kills the child and awaits its exit on scope exit. | ||
| } | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.