-
Notifications
You must be signed in to change notification settings - Fork 5.1k
Preserve a pending exception across the GC stack-trace finalizer #33584
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
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
dc3ed5f
vm: preserve a pending exception across the GC stack-trace finalizer
robobun b9cd27f
use SuspendExceptionScope to preserve the pending exception
robobun 91b4605
test: cover the plain import() face of the GC stack-trace finalizer race
robobun a7637d7
trim the finalizer comments to the invariant
robobun dad5963
one-line comment on the exception suspend
robobun 4edbfd4
test: name the Windows skips and stop masking unhandled rejections in…
robobun d970c98
Defer termination while the stack-trace finalizer suspends the exception
robobun 311565f
one-line comments per scope
robobun d661d2b
test: give the terminated worker its own small heap and listen for cl…
robobun 3bf076a
Merge branch 'main' into farm/8079e315/vm-module-eval-error-gc
robobun 105ca04
test: cover the Bun.resolve() face of the GC stack-trace finalizer race
robobun fadc1b2
ci: retrigger
robobun fb141e0
Merge branch 'main' into farm/8079e315/vm-module-eval-error-gc
robobun afb2e9c
test: add a deterministic check for the GC stack-trace finalizer race
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe } from "harness"; | ||
|
|
||
| // computeErrorInfoWrapperToString (src/jsc/bindings/FormatStackTraceForJS.cpp, | ||
| // the vm.setOnComputeErrorInfo hook) runs from | ||
| // ErrorInstance::reconcileWeakReferencesAtGCEnd during Heap::runEndPhase, to | ||
| // materialize the stack of a live Error whose frames have died. It used to | ||
| // clear whatever exception was pending on the VM. The entry module's | ||
| // evaluation promise is rejected with the caught exception after an allocation | ||
| // safepoint, so a collection ending in that window dropped the error: | ||
| // | ||
| // assert build: ASSERTION FAILED: exception JSPromise.cpp, rejectWithCaughtException | ||
| // release build: panic(main thread): Segmentation fault at address 0x8 | ||
| // | ||
| // Unlike the concurrent-GC reproducers (sourcetextmodule-link-gc.test.ts, | ||
| // dynamic-import-evaluation-error-gc.test.ts, the Bun.resolve() case in | ||
| // resolve-error.test.ts) this one is deterministic: slowPathAllocsBetweenGCs | ||
| // collects every N slow-path allocations, so the end phase lands in the same | ||
| // place every run. The Error is built inside an eval'd arrow so its frames are | ||
| // garbage by the time the throw propagates, which is what gives the end phase a | ||
| // stack to materialize. | ||
| // | ||
| // It is allocation-count sensitive, as that option is: on the unfixed debug | ||
| // build N of 3, 4 and 7 abort every run while 1, 2, 5, 6 and >= 8 print the | ||
| // error normally. Check the values that fire rather than one, since the counts | ||
| // shift with the build. A value that does not fire still has to print the | ||
| // error, so this cannot flake on a fixed build. | ||
| const ALLOCS_BETWEEN_GCS = [3, 4, 7]; | ||
|
|
||
| test.each(ALLOCS_BETWEEN_GCS)("an uncaught Error survives a GC end phase (every %i allocations)", async n => { | ||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "-e", `const e = eval("(() => Object.assign(new Error('c'), { code: 1 }))")(); throw e`], | ||
| env: { ...bunEnv, BUN_JSC_slowPathAllocsBetweenGCs: String(n) }, | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
| // Bun prints `error: c` plus the `code: 1` property and a frame. The crash | ||
| // produces an assertion or a segfault instead, and a nonzero-but-not-1 code. | ||
| expect({ stdout, startsWithError: stderr.startsWith("error: c"), exitCode }).toEqual({ | ||
| stdout: "", | ||
| startsWithError: true, | ||
| exitCode: 1, | ||
| }); | ||
| }); |
72 changes: 72 additions & 0 deletions
72
test/js/bun/resolve/dynamic-import-evaluation-error-gc.test.ts
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,72 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe, isWindows, tempDir } from "harness"; | ||
|
|
||
| // computeErrorInfoWrapperToString (src/jsc/bindings/FormatStackTraceForJS.cpp, | ||
| // installed via vm.setOnComputeErrorInfo) runs from | ||
| // ErrorInstance::reconcileWeakReferencesAtGCEnd during Heap::runEndPhase, for | ||
| // any live Error whose stack frames now reference dead code. It used to clear | ||
| // whatever exception was pending after computing the stack string. With | ||
| // concurrent GC that end phase can land while the mutator is parked at a | ||
| // safepoint inside CyclicModuleRecord::evaluate, between step 9 (the module's | ||
| // evaluation error is the pending exception) and step 9.d | ||
| // (rejectWithCaughtException), which then finds no exception and crashes | ||
| // (`ASSERTION FAILED: exception` at JSPromise.cpp on assert builds, a SEGV at | ||
| // address 0x8 on release: Sentry BUN-4R6E). | ||
| // | ||
| // This is the plain import() face of the bug. Each iteration writes a fresh | ||
| // ESM graph with one member that throws at top level and one that uses | ||
| // top-level await. import(a) fails on the throwing member; import(b) reaches | ||
| // that already-errored member, so its Evaluate() rethrows the stored error and | ||
| // runs the step 9 -> 9.d window again. The race needs the accumulated graphs | ||
| // and GC cycles of a long loop; a single iteration does not fire, 100 fire on | ||
| // about 1 run in 6, 300 on essentially every run of the unfixed assert build. | ||
| // The fixture runs twice and a single crash fails the test. | ||
| // | ||
| // Skipped on Windows: two runs of a 300-iteration loop that writes 2100 files | ||
| // and evaluates 1200 module graphs are several times slower under | ||
| // Windows + ASAN in CI, and the fixed C++ path is not platform-specific. | ||
| test.skipIf(isWindows)( | ||
| "import() of a module whose evaluation throws survives a GC stack-trace finalizer", | ||
| async () => { | ||
| const fixture = ` | ||
| import { mkdirSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
||
| const root = join(import.meta.dir, "graphs"); | ||
| for (let it = 0; it < 300; it++) { | ||
| const d = join(root, "g" + it); | ||
| mkdirSync(d, { recursive: true }); | ||
| writeFileSync(join(d, "bad.mjs"), "export const x = " + it + ";\\nthrow new Error('boom " + it + "');\\n"); | ||
| writeFileSync(join(d, "tla.mjs"), "await new Promise(r => setTimeout(r, 0));\\nexport const t = " + it + ";\\n"); | ||
| writeFileSync(join(d, "leaf.mjs"), "export const l = " + it + ";\\n"); | ||
| writeFileSync(join(d, "mid.mjs"), "import { l } from './leaf.mjs';\\nimport './bad.mjs';\\nexport const m = l + 1;\\n"); | ||
| writeFileSync(join(d, "a.mjs"), "import { m } from './mid.mjs';\\nimport { t } from './tla.mjs';\\nexport const a = m + t;\\n"); | ||
| writeFileSync(join(d, "b.mjs"), "import { t } from './tla.mjs';\\nimport './bad.mjs';\\nexport const b = t;\\n"); | ||
| writeFileSync(join(d, "c.mjs"), "import { m } from './mid.mjs';\\nexport const c = m;\\n"); | ||
|
|
||
| await import(join(d, "a.mjs")).catch(() => {}); | ||
| await import(join(d, "b.mjs")).catch(() => {}); | ||
| await import(join(d, "c.mjs")).catch(() => {}); | ||
| await import("data:text/javascript,throw new Error('d" + it + "')").catch(() => {}); | ||
| } | ||
| console.log("ok"); | ||
| `; | ||
|
|
||
| using dir = tempDir("dynamic-import-eval-error-gc", { "fixture.mjs": fixture }); | ||
|
|
||
| for (let i = 0; i < 2; i++) { | ||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "fixture.mjs"], | ||
| cwd: String(dir), | ||
| env: bunEnv, | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| }); | ||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
| expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "ok", exitCode: 0 }); | ||
| void stderr; | ||
| } | ||
| }, | ||
| // Two runs of a 300-iteration loop on a debug+ASAN build take well over the | ||
| // default 5s. | ||
| 120_000, | ||
| ); | ||
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
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.