Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export const setSyntheticAllocationLimitForTesting: (limit: number) => number =
1,
);

export const refStringsCount: () => number = $newRustFunction("virtual_machine_exports.rs", "Bun__refStringsCount", 0);

// Shrink the markdown parser's block-metadata cap (in bytes) so its
// `TooManyBlocks` error is reachable without 4 GiB of input. The cap can only
// be lowered, never raised past the real limit. Returns the previous value so
Expand Down
13 changes: 10 additions & 3 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3764,7 +3764,7 @@ impl VirtualMachine {
specifier,
source_url: create_if_different(&specifier, source_url),
allocator: source.cast::<c_void>(),
source_code_needs_deref: false,
source_code_needs_deref: true,
..Default::default()
}
}
Expand Down Expand Up @@ -3832,15 +3832,22 @@ impl VirtualMachine {
}
}

/// Interns `input_` in the VM's ref-string map and returns the ref-counted entry.
/// Interns `input_` in the VM's ref-string map and returns the entry with
/// exactly +1 owed to the caller on both fresh-insert and cache-hit paths.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub fn ref_counted_string<const DUPE: bool>(
&mut self,
input_: &[u8],
hash_: Option<u32>,
) -> *mut crate::ref_string::RefString {
debug_assert!(!input_.is_empty());
let mut was_new = false;
self.ref_counted_string_with_was_new::<DUPE>(&mut was_new, input_, hash_)
let r = self.ref_counted_string_with_was_new::<DUPE>(&mut was_new, input_, hash_);
if !was_new {
// SAFETY: `r` is live (held in `self.ref_strings`). A fresh entry
// already carries +1 from `create_external`; a cache hit does not.
unsafe { (*r).ref_() };
}
r
}

// Note: `flags` is a runtime arg —
Expand Down
5 changes: 5 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@
#include "EventLoopTask.h"
#include "NodeModuleModule.h"
#include <JavaScriptCore/JSCBytecodeCacheVersion.h>
#include <JavaScriptCore/CodeCache.h>
#include "JSPerformanceServerTiming.h"
#include "JSPerformanceResourceTiming.h"
#include "JSPerformanceTiming.h"
Expand Down Expand Up @@ -3536,6 +3537,10 @@ void GlobalObject::reload()
this->requireMap()->clear(this);
RETURN_IF_EXCEPTION(scope, );

// Stale entries (keyed by old source text) pin their SourceProvider; every
// module already re-transpiles after clearAll() so re-parsing is marginal.
Comment thread
robobun marked this conversation as resolved.
Outdated
vm.codeCache()->clear();

// If we run the GC every time, we will never get the SourceProvider cache hit.
// So we run the GC every other time.
if ((this->reloadCount++ + 1) % 2 == 0) {
Expand Down
7 changes: 7 additions & 0 deletions src/jsc/virtual_machine_exports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,3 +346,10 @@ pub fn Bun__setSyntheticAllocationLimitForTesting(
.store(limit, core::sync::atomic::Ordering::Relaxed);
Ok(JSValue::js_number(prev as f64))
}

/// Live entries in the VM's ref-string cache (native-heap; invisible to heapStats).
#[crate::host_fn(export = "Bun__refStringsCount")]
pub fn Bun__refStringsCount(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult<JSValue> {
let count = VirtualMachine::get().ref_strings.count();
Ok(JSValue::js_number(f64::from(count)))
}
13 changes: 2 additions & 11 deletions src/runtime/api/filesystem_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,18 +313,9 @@ impl FileSystemRouter {
root_dir_info.abs_path
};

// Note: `vm.refCountedString` is an interning cache — on a cache HIT it
// returns the existing `*mut RefString` WITHOUT bumping the refcount.
// `getScriptSrc`/`getOrigin` use `.leak()` (no ref), so without an
// explicit hold N routers sharing one interned RefString → N finalizers
// deref a single +1 → UAF on the second deref. Claim an explicit +1 here
// so each `FileSystemRouter` owns its hold; `finalize` releases it.
// `ref_counted_string` hands back +1; `finalize` releases it.
let claim = |p: *mut RefString| -> BackRef<RefString> {
// `ref_counted_string` returns a live interned `*mut RefString`; wrap as
// `BackRef` (owner-outlives-holder: VM intern cache + our +1).
let r = BackRef::from(core::ptr::NonNull::new(p).expect("ref_counted_string"));
r.ref_();
r
BackRef::from(core::ptr::NonNull::new(p).expect("ref_counted_string"))
};
let fs_router = Box::new(FileSystemRouter {
origin: if !origin_str.slice().is_empty() {
Expand Down
1 change: 1 addition & 0 deletions src/runtime/dispatch_js2native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub use bun_install_jsc::ini_jsc::ini_testing_parse as ini_ini_ini_testing_ap_is
pub use bun_jsc::bindgen_test::get_bindgen_test_functions as jsc_bindgen_test_get_bindgen_test_functions;
pub use bun_jsc::counters::create_counters_object as jsc_counters_create_counters_object;
pub use bun_jsc::event_loop::get_active_tasks as jsc_event_loop_get_active_tasks;
pub use bun_jsc::virtual_machine_exports::Bun__refStringsCount as jsc_virtual_machine_exports_bun__ref_strings_count;
pub use bun_jsc::virtual_machine_exports::Bun__setSyntheticAllocationLimitForTesting as jsc_virtual_machine_exports_bun__set_synthetic_allocation_limit_for_testing;
// `emit_handle_ipc_message` is implemented in this crate (`ipc_host.rs`)
// because it dereferences `Subprocess`, a runtime type.
Expand Down
1 change: 0 additions & 1 deletion test/cli/hot/hot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,6 @@ ${Buffer.alloc(counter * 2, " ").toString()}throw new Error(${counter});`,
expect(reloadCounter).toBe(50);
bundler.kill();
await runner.exited;
// TODO: bun has a memory leak when --hot is used on very large files
},
longTimeout,
);
83 changes: 83 additions & 0 deletions test/regression/issue/11083.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// https://github.com/oven-sh/bun/issues/11083

import { spawn } from "bun";
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isDebug, tempDir } from "harness";
import { writeFileSync } from "node:fs";
import { join } from "node:path";

test(
"bun --hot should not accumulate stale code blocks or ref-string entries when file content changes on every reload",
async () => {
using dir = tempDir("hot-11083", {});
const root = join(String(dir), "leak-runner.mjs");

// Small file so debug builds cycle faster than JSC's ~10s CodeCache prune
// timer; unique content each iteration so the cache key never repeats.
const writeSource = (iter: number) => {
writeFileSync(
root,
`var unused_${iter} = ${iter};
Bun.gc(true);
globalThis.__i = (globalThis.__i ?? 0) + 1;
const s = require("bun:jsc").heapStats();
const { refStringsCount } = require("bun:internal-for-testing");
console.error(JSON.stringify({
i: globalThis.__i,
umpcb: s.objectTypeCounts.UnlinkedModuleProgramCodeBlock || 0,
refs: refStringsCount(),
}));
`,
);
};

let iter = 0;
writeSource(++iter);

await using runner = spawn({
cmd: [bunExe(), "--hot", "run", root],
env: bunEnv,
cwd: String(dir),
stdout: "ignore",
stderr: "pipe",
stdin: "ignore",
});

const target = 50;
let maxCodeBlocks = 0;
let maxRefStrings = 0;
let reached = 0;
let buf = "";
outer: for await (const chunk of runner.stderr!) {
buf += new TextDecoder().decode(chunk);
let nl: number;
let progressed = false;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
if (!line.startsWith("{")) {
if (/Watcher crashed|panic:|oh no:|TypeError:|is not a function/.test(line)) {
throw new Error("child --hot died: " + line);
}
continue;
}
const { i, umpcb, refs } = JSON.parse(line);
reached = i;
maxCodeBlocks = Math.max(maxCodeBlocks, umpcb);
maxRefStrings = Math.max(maxRefStrings, refs);
progressed = true;
if (i >= target) {
runner.kill();
break outer;
}
}
if (progressed) writeSource(++iter);
}

expect(reached).toBe(target);
expect(maxCodeBlocks).toBeLessThan(10);
expect(maxRefStrings).toBeGreaterThan(0);
expect(maxRefStrings).toBeLessThan(10);
},
isDebug ? 60_000 : 20_000,
);
Loading