Skip to content
Open
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ warnings = { level = "deny", priority = -1 }
# (`--cfg=...` + `--check-cfg=cfg(...)`) by scripts/build/rust.ts; register
# them here so a plain `cargo build` / `cargo check` (without those flags)
# doesn't warn.
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(bun_asan)', 'cfg(bun_debug)', 'cfg(socket_fault_injection)'] }
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(bun_asan)', 'cfg(bun_debug)', 'cfg(socket_fault_injection)', 'cfg(bun_track_alloc)'] }
# link.exe unconditionally prints "Creating library X.dll.lib and object
# X.dll.exp" to stdout when linking each proc-macro DLL on Windows hosts;
# there is no linker flag to suppress it. The lint already exempts itself
Expand Down
9 changes: 9 additions & 0 deletions scripts/build/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ export interface Config {
* acquire atomic load per syscall, zero when compiled out.
*/
socketFaultInjection: boolean;
/**
* Wrap the Rust global allocator in a per-size-bucket live-byte histogram
* (src/bun_bin/track_alloc.rs). Two atomic RMWs per alloc/free; off by
* default. Read out via `hotReloadDiagnostics().allocHistogram`.
*/
trackAlloc: boolean;
/** Bundle small .cpp files into unified TUs (WebKit-style). See unified.ts. */
unifiedSources: boolean;
/**
Expand Down Expand Up @@ -350,6 +356,7 @@ export interface PartialConfig {
valgrind?: boolean;
fuzzilli?: boolean;
socketFaultInjection?: boolean;
trackAlloc?: boolean;
unifiedSources?: boolean;
archiveDeps?: boolean;
timeTrace?: boolean;
Expand Down Expand Up @@ -901,6 +908,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con
// memory errors are detectable, and the disarmed-hot-path cost (one acquire
// atomic load) is acceptable in asan builds but not in shipped release.
const socketFaultInjection = partial.socketFaultInjection ?? asan;
const trackAlloc = partial.trackAlloc ?? process.env.BUN_TRACK_ALLOC === "1";

// ─── Paths ───
const cwd = findRepoRoot();
Expand Down Expand Up @@ -1204,6 +1212,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con
valgrind,
fuzzilli,
socketFaultInjection,
trackAlloc,
unifiedSources: partial.unifiedSources ?? true,
archiveDeps: partial.archiveDeps ?? false,
timeTrace: partial.timeTrace ?? false,
Expand Down
7 changes: 7 additions & 0 deletions scripts/build/rust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,13 @@ export function cargoBuildInvocation(cfg: Config): CargoInvocation {
if (cfg.socketFaultInjection) {
rustflags.push("--cfg=socket_fault_injection");
}
// `bun_track_alloc`: wraps the global allocator in a per-size-bucket live
// histogram (src/bun_bin/track_alloc.rs), readable from JS via
// `hotReloadDiagnostics().allocHistogram`. See `Config.trackAlloc`.
rustflags.push("--check-cfg=cfg(bun_track_alloc)");
if (cfg.trackAlloc) {
rustflags.push("--cfg=bun_track_alloc");
Comment thread
robobun marked this conversation as resolved.
}
// Drop `#[track_caller]` source-location capture in release. Every
// `Option::unwrap`/`slice[i]`/`RefCell::borrow` etc. otherwise emits a
// `&'static core::panic::Location` (file/line/col) plus the file-path string
Expand Down
15 changes: 13 additions & 2 deletions src/bun_bin/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,27 @@ use bun_core::Global;
use bun_core::StackCheck;
use bun_core::output;

#[cfg(bun_track_alloc)]
mod track_alloc;

/// mimalloc as the process allocator.
#[cfg(not(bun_asan))]
#[cfg(all(not(bun_asan), not(bun_track_alloc)))]
#[global_allocator]
static ALLOC: bun_alloc::Mimalloc = bun_alloc::Mimalloc;

/// Under ASAN, use the system allocator so the interceptor sees every allocation.
#[cfg(bun_asan)]
#[cfg(all(bun_asan, not(bun_track_alloc)))]
#[global_allocator]
static ALLOC: std::alloc::System = std::alloc::System;

#[cfg(all(not(bun_asan), bun_track_alloc))]
#[global_allocator]
static ALLOC: track_alloc::Tracked<bun_alloc::Mimalloc> = track_alloc::Tracked(bun_alloc::Mimalloc);

#[cfg(all(bun_asan, bun_track_alloc))]
#[global_allocator]
static ALLOC: track_alloc::Tracked<std::alloc::System> = track_alloc::Tracked(std::alloc::System);

/// ASAN runtime options override. Lives in the binary crate so it is a direct
/// link input — the ASAN runtime weak-defines this symbol, and an rlib/archive
/// member that only provides it would never be extracted, so the override in
Expand Down
80 changes: 80 additions & 0 deletions src/bun_bin/track_alloc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//! Per-size-bucket live-allocation histogram for the Rust global allocator.
//!
//! Only compiled when `cfg(bun_track_alloc)` is set (via `BUN_TRACK_ALLOC=1`
//! at build time). Wraps the real allocator and maintains `(bytes, count)`
//! counters per power-of-two size bucket, readable from JS via
//! `hotReloadDiagnostics().allocHistogram`. This surfaces reachable-but-growing
//! native memory that LSAN (unreachable-only) cannot see.
Comment thread
robobun marked this conversation as resolved.

use core::alloc::{GlobalAlloc, Layout};
use core::sync::atomic::{AtomicI64, Ordering};

const BUCKETS: usize = 32;
static LIVE_BYTES: [AtomicI64; BUCKETS] = [const { AtomicI64::new(0) }; BUCKETS];
static LIVE_COUNT: [AtomicI64; BUCKETS] = [const { AtomicI64::new(0) }; BUCKETS];

#[inline]
fn bucket(size: usize) -> usize {
let b = usize::BITS - size.max(1).leading_zeros();
(b as usize).min(BUCKETS - 1)
}

#[inline]
fn add(size: usize) {
let b = bucket(size);
LIVE_BYTES[b].fetch_add(size as i64, Ordering::Relaxed);
LIVE_COUNT[b].fetch_add(1, Ordering::Relaxed);
}

#[inline]
fn sub(size: usize) {
let b = bucket(size);
LIVE_BYTES[b].fetch_sub(size as i64, Ordering::Relaxed);
LIVE_COUNT[b].fetch_sub(1, Ordering::Relaxed);
}

pub(crate) struct Tracked<A: GlobalAlloc>(pub(crate) A);

unsafe impl<A: GlobalAlloc> GlobalAlloc for Tracked<A> {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let p = unsafe { self.0.alloc(layout) };
if !p.is_null() {
add(layout.size());
}
p
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
sub(layout.size());
unsafe { self.0.dealloc(ptr, layout) }
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
let p = unsafe { self.0.alloc_zeroed(layout) };
if !p.is_null() {
add(layout.size());
}
p
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
let p = unsafe { self.0.realloc(ptr, layout, new_size) };
if !p.is_null() {
sub(layout.size());
add(new_size);
}
p
}
}

/// Writes `BUCKETS` pairs of `(live_bytes, live_count)` into `out` (caller
/// provides `out_len` i64 slots). Returns the bucket count written.
Comment thread
robobun marked this conversation as resolved.
#[unsafe(no_mangle)]
pub(crate) extern "C" fn Bun__trackedAllocHistogram(out: *mut i64, out_len: usize) -> usize {
let n = BUCKETS.min(out_len / 2);
for i in 0..n {
// SAFETY: caller passes a buffer of `out_len` i64s.
unsafe {
*out.add(i * 2) = LIVE_BYTES[i].load(Ordering::Relaxed);
*out.add(i * 2 + 1) = LIVE_COUNT[i].load(Ordering::Relaxed);
}
}
n
}
16 changes: 16 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ const shellParse = $newRustFunction("shell.rs", "TestingAPIs.shellParse", 2);

export const sslCtxLiveCount = $newRustFunction("SecureContext.rs", "jsLiveCount", 0);

export const hotReloadDiagnostics = $newRustFunction(
"virtual_machine_exports.rs",
"Bun__hotReloadDiagnostics",
0,
) as () => {
refStrings: number;
sourceMappings: number;
resolvedPathDups: number;
watchlistLen: number;
hotReloadCounter: number;
/** Only present when built with `BUN_TRACK_ALLOC=1`. */
allocHistogram?: { bucket: number; bytes: number; count: number }[];
/** Only present when built with `BUN_TRACK_ALLOC=1`. */
allocLiveBytes?: number;
};

export const napiThreadsafeFunctionLiveCount = $newRustFunction("napi_body.rs", "jsThreadsafeFunctionLiveCount", 0);

export const escapeRegExp = $newRustFunction("escapeRegExp.rs", "jsEscapeRegExp", 1);
Expand Down
2 changes: 1 addition & 1 deletion src/jsc/AsyncModule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1307,7 +1307,7 @@ impl AsyncModule {
if unsafe { (*jsc_vm).is_watcher_enabled() } {
// SAFETY: per-thread VM.
let mut resolved_source = unsafe {
(*jsc_vm).ref_counted_resolved_source::<false>(
(*jsc_vm).ref_counted_resolved_source(
printer.ctx.get_written(),
BunString::init(specifier),
path.text,
Expand Down
58 changes: 58 additions & 0 deletions src/jsc/RefString.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,64 @@ pub struct RefString {
pub(crate) on_before_deinit: Option<Callback>,
}

/// RAII owner of one reference to an interned [`RefString`] (one
/// `WTF::StringImpl` refcount). Mirrors `bun_core::OwnedString`: `Drop`
/// releases the reference, `Clone` takes another one, and
/// [`OwnedRefString::into_raw`] transfers it to a consumer that will release
/// it (e.g. C++ via `ResolvedSource.source_code_needs_deref`).
///
/// Intentionally no `Deref` to [`RefString`]: `RefString::ref_`/`deref` are
/// the raw refcount ops this type exists to encapsulate, so reaching them
/// must go through the explicit [`OwnedRefString::get`].
Comment thread
robobun marked this conversation as resolved.
pub struct OwnedRefString(NonNull<RefString>);

impl OwnedRefString {
/// Adopt a reference the caller already owns (e.g. the +1
/// `String::create_external` leaves on a fresh entry).
///
/// # Safety
/// `p` points at a live `RefString` and the caller transfers exactly one
/// owned reference.
Comment thread
robobun marked this conversation as resolved.
pub(crate) unsafe fn adopt(p: NonNull<RefString>) -> Self {
Self(p)
}

/// Take a new reference on a live `RefString`.
///
/// # Safety
/// `p` points at a `RefString` that stays live for the duration of this
/// call (e.g. it sits in `ref_strings` under `ref_strings_mutex`).
Comment thread
robobun marked this conversation as resolved.
pub(crate) unsafe fn claim(p: NonNull<RefString>) -> Self {
// SAFETY: caller contract — `p` is live for this call.
unsafe { p.as_ref() }.ref_();
Self(p)
}

pub fn get(&self) -> &RefString {
// SAFETY: `self` owns a reference, so the pointee is live.
unsafe { self.0.as_ref() }
}

/// Disarm the drop guard and hand the owned reference to the caller, who
/// becomes responsible for the matching `deref`.
Comment thread
robobun marked this conversation as resolved.
pub fn into_raw(self) -> *mut RefString {
core::mem::ManuallyDrop::new(self).0.as_ptr()
}
}

impl Clone for OwnedRefString {
fn clone(&self) -> Self {
// SAFETY: `self` owns a reference, so the pointee is live.
unsafe { Self::claim(self.0) }
}
}

impl Drop for OwnedRefString {
fn drop(&mut self) {
self.get().deref();
}
}

impl RefString {
pub(crate) fn compute_hash(input: &[u8]) -> u32 {
bun_hash::XxHash32::hash(0, input)
Expand Down
65 changes: 25 additions & 40 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3025,7 +3025,7 @@ unsafe extern "C" {

extern "C" fn free_ref_string(str_: *mut crate::ref_string::RefString, _: *mut c_void, _: usize) {
// SAFETY: `str_` is the `ctx` we passed to `String::create_external` in
// `ref_counted_string_with_was_new`; it points at a heap `RefString`.
// `ref_counted_string`; it points at a heap `RefString`.
unsafe { crate::ref_string::RefString::destroy(str_) };
}

Expand Down Expand Up @@ -3663,19 +3663,19 @@ impl VirtualMachine {
}

/// Stored as [`RefString::on_before_deinit`] (an unsafe-fn-ptr slot) in
/// [`ref_counted_string_with_was_new`]; only ever invoked from
/// [`ref_counted_string`]; only ever invoked from
/// `RefString::destroy` with the live `*mut RefString` being torn down.
fn clear_ref_string(_: *mut c_void, ref_string: *mut crate::ref_string::RefString) {
// SAFETY: only reachable via `RefString::destroy`, which passes the
// live heap `RefString` allocated in `ref_counted_string_with_was_new`;
// live heap `RefString` allocated in `ref_counted_string`;
// safe-fn coerces to the unsafe-fn-ptr `Callback` slot type.
let hash = unsafe { &*ref_string }.hash;
// SAFETY: `get()` is the live per-thread VM.
VirtualMachine::get().as_mut().ref_strings.remove(&hash);
}

/// Builds a `ResolvedSource` backed by a ref-counted copy of `code` interned in the VM's ref-string map.
pub fn ref_counted_resolved_source<const ADD_DOUBLE_REF: bool>(
pub fn ref_counted_resolved_source(
&mut self,
code: &[u8],
specifier: bun_core::String,
Expand All @@ -3692,38 +3692,29 @@ impl VirtualMachine {
..Default::default()
};
}
// Const-generic bool can't be `!ADD_DOUBLE_REF`, so branch.
let source = if ADD_DOUBLE_REF {
self.ref_counted_string::<false>(code, hash_)
} else {
self.ref_counted_string::<true>(code, hash_)
};
// SAFETY: `ref_counted_string` returns a live `*mut RefString` held in
// `self.ref_strings`; we own +1 (or +3 below) until JSC calls the
// external-string finalizer.
let source_ref = unsafe { &*source };
if ADD_DOUBLE_REF {
source_ref.ref_();
source_ref.ref_();
}
let source = self.ref_counted_string::<true>(code, hash_);
let source_code = bun_core::String::adopt_wtf_impl(source.get().impl_);

ResolvedSource {
source_code: bun_core::String::adopt_wtf_impl(source_ref.impl_),
source_code,
specifier,
source_url: create_if_different(&specifier, source_url),
allocator: source.cast::<c_void>(),
source_code_needs_deref: false,
// `source_code_needs_deref` makes the consumer release the
// reference transferred here.
Comment thread
robobun marked this conversation as resolved.
allocator: source.into_raw().cast::<c_void>(),
source_code_needs_deref: true,
..Default::default()
}
}

fn ref_counted_string_with_was_new<const DUPE: bool>(
/// Interns `input_` in the VM's ref-string map and returns an owned
/// reference to the entry.
Comment thread
robobun marked this conversation as resolved.
pub fn ref_counted_string<const DUPE: bool>(
&mut self,
new: &mut bool,
input_: &[u8],
hash_: Option<u32>,
) -> *mut crate::ref_string::RefString {
use crate::ref_string::RefString;
) -> crate::ref_string::OwnedRefString {
use crate::ref_string::{OwnedRefString, RefString};
use bun_collections::zig_hash_map::MapEntry as Entry;
jsc::mark_binding();
debug_assert!(!input_.is_empty());
Expand All @@ -3737,8 +3728,11 @@ impl VirtualMachine {

match self.ref_strings.entry(hash) {
Entry::Occupied(o) => {
*new = false;
*o.get()
// SAFETY: the entry is live while it sits in `ref_strings`
// under `ref_strings_mutex` (map pointers are non-null by
// construction); `claim` secures the returned reference
// before the lock drops.
unsafe { OwnedRefString::claim(NonNull::new_unchecked(*o.get())) }
}
Entry::Vacant(v) => {
// Dupe the input bytes when `DUPE`, otherwise
Expand Down Expand Up @@ -3774,23 +3768,14 @@ impl VirtualMachine {
// SAFETY: see above.
unsafe { (*ref_).impl_ = s.leak_wtf_impl() };
v.insert(ref_);
*new = true;
ref_
// SAFETY: `ref_` is non-null (just boxed); `create_external`
// left one reference on the fresh impl, which the owner
// adopts.
unsafe { OwnedRefString::adopt(NonNull::new_unchecked(ref_)) }
}
}
}

/// Interns `input_` in the VM's ref-string map and returns the ref-counted entry.
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_)
}

// Note: `flags` is a runtime arg —
// `FetchFlags` would need `ConstParamTy` (unstable derive on the enum's
// owning module) to be a const generic; the only branches are cheap
Expand Down
Loading