-
Notifications
You must be signed in to change notification settings - Fork 5.1k
bake: unregister Bake::SourceProvider from the source map table when it is destroyed #37444
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ namespace Bake { | |
| class SourceProvider; | ||
|
|
||
| extern "C" void Bun__addBakeSourceProviderSourceMap(void* bun_vm, SourceProvider* opaque_source_provider, const BunString* specifier); | ||
| extern "C" void Bun__removeBakeSourceProviderSourceMap(void* bun_vm, SourceProvider* opaque_source_provider, const BunString* specifier); | ||
|
|
||
| class SourceProvider final : public JSC::StringSourceProvider { | ||
| public: | ||
|
|
@@ -50,6 +51,15 @@ class SourceProvider final : public JSC::StringSourceProvider { | |
| { | ||
| } | ||
|
|
||
| // Takes the Rust VirtualMachine, not the Zig::GlobalObject: this runs | ||
| // from JSC's sweep, possibly after the global object cell itself has been | ||
| // swept (see DevServerSourceProvider). | ||
|
Comment on lines
+54
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code |
||
| ~SourceProvider() | ||
| { | ||
| auto specifier = Bun::toString(sourceURL()); | ||
| Bun__removeBakeSourceProviderSourceMap(m_bunVM, this, &specifier); | ||
| } | ||
|
|
||
| void* m_bunVM; | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,9 @@ | ||
| //! Rust side of `BakeSourceProvider.h` / `DevServerSourceProvider.h`: the | ||
| //! FFI bindings and `SourceProvider` impls for bake's two C++ source | ||
| //! providers, plus the host exports that register them with the VM's | ||
| //! `SavedSourceMap` so stack remapping can resolve dev-server / | ||
| //! bake-production output. `bun_sourcemap` sees these only as erased | ||
| //! `AnySourceProvider` handles. | ||
| //! providers, plus the host exports that register them with (and, from their | ||
| //! destructors, remove them from) the VM's `SavedSourceMap` so stack remapping | ||
| //! can resolve dev-server / bake-production output. `bun_sourcemap` sees these | ||
| //! only as erased `AnySourceProvider` handles. | ||
|
Comment on lines
+3
to
+6
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code |
||
| //! | ||
| //! `#[unsafe(no_mangle)] extern "C"` thunks are emitted by | ||
| //! `src/codegen/generate-host-exports.ts` from the `// HOST_EXPORT(Sym, c)` | ||
|
|
@@ -18,8 +18,8 @@ use bun_sourcemap::parsed_source_map::AnySourceProvider; | |
| use bun_sourcemap::{SourceContentPtr, SourceProvider}; | ||
|
|
||
| bun_opaque::opaque_ffi! { | ||
| /// Opaque handle to the C++ `Bake::SourceProvider` for production-build | ||
| /// sources (`BakeSourceProvider.cpp`). | ||
| /// Opaque handle to the C++ `Bake::SourceProvider` (`BakeSourceProvider.h`), | ||
| /// registered from its `create()` and unregistered from its destructor. | ||
|
Comment on lines
+21
to
+22
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code |
||
| pub(crate) struct BakeSourceProvider; | ||
| /// Opaque handle to the C++ `Bake::DevServerSourceProvider` | ||
| /// (`DevServerSourceProvider.cpp`). | ||
|
|
@@ -146,6 +146,17 @@ pub(crate) fn add_bake_source_provider_source_map( | |
| ); | ||
| } | ||
|
|
||
| // HOST_EXPORT(Bun__removeBakeSourceProviderSourceMap, c) | ||
| pub(crate) fn remove_bake_source_provider_source_map( | ||
| vm: &mut VirtualMachine, | ||
| opaque_source_provider: *mut c_void, | ||
| specifier: &BunString, | ||
| ) { | ||
| let slice = specifier.to_utf8(); | ||
| vm.source_mappings | ||
| .remove_source_provider(opaque_source_provider, slice.slice()); | ||
| } | ||
|
|
||
| // HOST_EXPORT(Bun__addDevServerSourceProvider, c) | ||
| pub(crate) fn add_dev_server_source_provider( | ||
| vm: &mut VirtualMachine, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,5 @@ | ||||||||
| import { expect, test } from "bun:test"; | ||||||||
| import { bunEnv, bunExe, tempDir } from "harness"; | ||||||||
| import { bunEnv, bunExe, isASAN, tempDir } from "harness"; | ||||||||
| import path from "node:path"; | ||||||||
|
|
||||||||
| test("dev server deinitializes itself", () => { | ||||||||
|
|
@@ -50,3 +50,104 @@ test("dev server is deinitialized before its arena when listen fails", async () | |||||||
| expect(stdout).toBe('{"code":"EADDRINUSE","deinits":1}\n'); | ||||||||
| expect(exitCode).toBe(0); | ||||||||
| }); | ||||||||
|
|
||||||||
| // Every framework dev server evaluates the server runtime through its own | ||||||||
| // Bake::SourceProvider, registered in the VM's source map table under the one | ||||||||
| // key "bake://server-runtime.js", so the table points at whichever server | ||||||||
| // started last. Once that server is torn down and its provider freed, the | ||||||||
| // next stack trace through a surviving server's runtime looks the map up via | ||||||||
| // the table entry, so the provider must have removed itself by then. Malloc=1 | ||||||||
| // puts JSC's allocations under the system malloc so ASAN sees the read of the | ||||||||
| // freed provider instead of bmalloc quietly handing back the stale memory. | ||||||||
| test.skipIf(!isASAN)( | ||||||||
| "tearing down one dev server does not leave its server runtime source provider registered for another", | ||||||||
| async () => { | ||||||||
| using dir = tempDir("bake-deinit-source-provider", { | ||||||||
| "server.ts": ` | ||||||||
| export function render(req, meta) { | ||||||||
| return meta.pageModule.default(req, meta); | ||||||||
| } | ||||||||
| export function registerClientReference(value, file, uid) { | ||||||||
| return { value, file, uid }; | ||||||||
| } | ||||||||
| `, | ||||||||
| "routes/index.ts": ` | ||||||||
| export default function () { | ||||||||
| return new Response(new Error("probe").stack); | ||||||||
| } | ||||||||
| `, | ||||||||
| "fixture.ts": ` | ||||||||
| import { getDevServerDeinitCount } from "bun:internal-for-testing"; | ||||||||
| import path from "node:path"; | ||||||||
|
|
||||||||
| const serverEntryPoint = path.join(import.meta.dir, "server.ts"); | ||||||||
| const framework = { | ||||||||
| fileSystemRouterTypes: [{ root: "routes", style: "nextjs-pages", serverEntryPoint }], | ||||||||
| serverComponents: { | ||||||||
| separateSSRGraph: false, | ||||||||
| serverRuntimeImportSource: serverEntryPoint, | ||||||||
| serverRegisterClientReferenceExport: "registerClientReference", | ||||||||
| }, | ||||||||
| }; | ||||||||
| const start = () => Bun.serve({ port: 0, app: { framework } }); | ||||||||
|
|
||||||||
| async function collect(isDone) { | ||||||||
| for (let i = 0; i < 200 && !isDone(); i++) { | ||||||||
| Bun.gc(true); | ||||||||
| await new Promise(resolve => setTimeout(resolve, 1)); | ||||||||
| } | ||||||||
| if (!isDone()) throw new Error("dev server was not deinitialized"); | ||||||||
| } | ||||||||
|
|
||||||||
| const survivor = start(); | ||||||||
|
|
||||||||
| // Started second, so its provider replaces the survivor's table entry. | ||||||||
| // Stopped inside its own function so nothing on the stack keeps it alive. | ||||||||
| const deinitsBefore = getDevServerDeinitCount(); | ||||||||
| const survivorDebugHooks = globalThis.DEBUG; | ||||||||
| (function startAndStop() { | ||||||||
| start().stop(true); | ||||||||
| })(); | ||||||||
| // Debug builds of the server runtime publish globalThis.DEBUG, whose | ||||||||
| // ASSERT is a function of whichever runtime ran last and so would keep | ||||||||
| // the second server's provider alive (release builds compile it out). | ||||||||
| // Put the survivor's back; it is the one that keeps handling requests. | ||||||||
| globalThis.DEBUG = survivorDebugHooks; | ||||||||
| await collect(() => getDevServerDeinitCount() > deinitsBefore); | ||||||||
| // Deinit dropped the dev server's references to the runtime's | ||||||||
| // functions; a few more collections sweep them and free the provider. | ||||||||
| for (let i = 0; i < 5; i++) { | ||||||||
| Bun.gc(true); | ||||||||
| await new Promise(resolve => setTimeout(resolve, 1)); | ||||||||
|
Comment on lines
+116
to
+121
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Maintainers get a regression test that can pass on a build that still has the use-after-free, so a reintroduction of the bug merges green. The fixture at test/bake/deinitialization.test.ts:116-121 runs a fixed 5 extra Why this was flaggedThe trigger is the provider surviving the 5 fixed collections at test/bake/deinitialization.test.ts:118-121. The test's only signal is the fetch at :123 producing an ASAN report; with the provider still alive, the SavedSourceMap entry (registered at src/runtime/bake/BakeSourceProvider.h:26-27) still points at valid memory and no report is produced, so Verification: Triggers whenever anything keeps the second server's Bake::SourceProvider alive past the fixed 5 collections at test/bake/deinitialization.test.ts:119-122; the fixture then fetches at :124 and the only failure signal is an ASAN report in stderr (:147), so an unfixed build with a live provider prints |
||||||||
| } | ||||||||
|
|
||||||||
| const response = await fetch(survivor.url); | ||||||||
| const stack = await response.text(); | ||||||||
| survivor.stop(true); | ||||||||
| console.log("status:", response.status); | ||||||||
| console.log("runtime frame:", stack.includes("bake://server-runtime.js")); | ||||||||
| `, | ||||||||
| }); | ||||||||
|
|
||||||||
| await using proc = Bun.spawn({ | ||||||||
| cmd: [bunExe(), "fixture.ts"], | ||||||||
| cwd: String(dir), | ||||||||
| env: { | ||||||||
| ...bunEnv, | ||||||||
| Malloc: "1", | ||||||||
| // detect_leaks=0: Malloc=1 exposes JSC's process-lifetime startup | ||||||||
| // allocations to LSAN; this test is about the use-after-free. | ||||||||
| ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), | ||||||||
| }, | ||||||||
| stdout: "pipe", | ||||||||
| stderr: "pipe", | ||||||||
| }); | ||||||||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||||||||
|
|
||||||||
| expect(stderr).not.toContain("AddressSanitizer"); | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): the new test asserts the absence of "AddressSanitizer" in stderr, an output-absence check CLAUDE.md:100 forbids, and it is weaker than the sibling test's exact check. An ASAN report already fails the test through expect(exitCode).toBe(0) at test/bake/deinitialization.test.ts:150, so the not.toContain adds nothing and lets any other stderr noise pass. Fix: assert the exact expected stderr as the neighbouring test does (expect(stderr).toBe("") at test/bake/deinitialization.test.ts:48), or drop the absence check and rely on stdout and exitCode. Why this was flaggedtest/bake/deinitialization.test.ts:147 is expect(stderr).not.toContain("AddressSanitizer"). The root CLAUDE.md:100 rule says never to write tests that check for the absence of a crash string in output because they do not fail in CI. The assertion is redundant: an ASAN heap-use-after-free aborts the child with a nonzero exit, which test/bake/deinitialization.test.ts:150 already catches, and it does not catch any other unexpected stderr output (warnings, debug logs) that the sibling test at test/bake/deinitialization.test.ts:48 would catch with toBe(""). Nothing user-visible changes; this is a test-convention nit relative to the base branch, which did not contain this test. Verification: nit. test/bake/deinitialization.test.ts:147 is |
||||||||
| // The runtime frame is what sends the stack trace through the table entry. | ||||||||
| expect(stdout).toEndWith("status: 200\nruntime frame: true\n"); | ||||||||
| expect(exitCode).toBe(0); | ||||||||
| }, | ||||||||
| 60_000, | ||||||||
|
Comment on lines
+151
to
+152
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Remove the explicit The coding guidelines prohibit per-test timeouts. Bun already applies its own timeouts. Remove the explicit value. If ASAN runtime needs more time, configure that in the runner and not in the test. As per coding guidelines: "CRITICAL: Do not set a timeout on tests. Bun already has timeouts." ♻️ Proposed fix expect(exitCode).toBe(0);
},
- 60_000,
);📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Maintainers get a 60-second per-test timeout with no stated reason, so a future hang in this ASAN-only case costs a full minute of CI before failing with no hint why the long bound is needed. The sibling test at test/bake/deinitialization.test.ts:14-16 justifies its identical 60_000 with a comment; the new one at :152 does not, which test/CLAUDE.md and REVIEW.md both flag (per-test timeouts only for stated rare outliers). Fix: either drop the timeout and shrink the workload (the fixture already bounds collect() at 200 passes) or keep it with a one-line comment naming the measured ASAN duration, matching the sibling at :14-16. Why this was flaggedThe test at test/bake/deinitialization.test.ts:60-153 is gated to ASAN builds only, where it starts two framework dev servers and runs up to 205 Bun.gc(true) passes plus a fetch. The trailing 60_000 argument at :152 raises the default 5s timeout twelvefold. The same file's first test justifies the same number with a comment at :14-15 ("takes longer than the 5s default under ASAN"); the new test has none, so a reader cannot tell whether 60s is a measured bound or a guess, and a regression that makes the child hang (for example the dev server not deinitializing, which collect() only throws for after 200 iterations of 1ms sleeps plus GC) burns the full minute before reporting. On the base branch this test did not exist. Remedy: add the justifying comment or remove the timeout and tighten the fixture. Verification: The new test in test/bake/deinitialization.test.ts (lines 60-153) ends with a bare |
||||||||
| ); | ||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code