Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
13 changes: 10 additions & 3 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3712,7 +3712,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 All @@ -3738,7 +3738,12 @@ impl VirtualMachine {
match self.ref_strings.entry(hash) {
Entry::Occupied(o) => {
*new = false;
*o.get()
let r = *o.get();
// SAFETY: `r` is live while it sits in `ref_strings` under
// `ref_strings_mutex`. Take the caller's +1 here so it is
// secured before the lock drops.
unsafe { (*r).ref_() };
r
}
Entry::Vacant(v) => {
// Dupe the input bytes when `DUPE`, otherwise
Expand Down Expand Up @@ -3780,14 +3785,16 @@ impl VirtualMachine {
}
}

/// Interns `input_` in the VM's ref-string map and returns the ref-counted entry.
/// Interns `input_` and returns the entry with exactly +1 owed to the caller.
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;
// Fresh entries have +1 from `create_external`; the Occupied arm
// takes +1 under `ref_strings_mutex`.
Comment thread
robobun marked this conversation as resolved.
Outdated
self.ref_counted_string_with_was_new::<DUPE>(&mut was_new, input_, hash_)
}

Expand Down
14 changes: 11 additions & 3 deletions src/jsc/hot_reloader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -934,8 +934,7 @@ where

let affected_len: usize = 'brk: {
if IS_KQUEUE {
// SAFETY: hot-reload runs single-threaded on the JS thread;
// no other live `&mut EntriesOption` for this key here.
// Slot contents are read under `entries_mutex` below.
if let Some(existing) = rfs.entries.get(file_path) {
self.put_tombstone(file_path, existing);
entries_option = Some(existing);
Expand Down Expand Up @@ -1087,12 +1086,21 @@ where
}
}

if let Some(dir_ent) = entries_option {
'locked: {
let Some(dir_ent) = entries_option else {
break 'locked;
};
// A stale `DirEntry` slot can be rewritten in place by a
// JS-thread resolve; serialize with those writers.
Comment thread
robobun marked this conversation as resolved.
let _entries_g = rfs.entries_mutex.lock_guard();
// SAFETY: dir_ent points into rfs.entries (or a tombstoned copy);
// both outlive this loop iteration. Shared access only —
// `entries()` takes `&self` and per-entry mutation below goes
// through the entry's own mutex + cells.
let dir_ent = unsafe { &*dir_ent };
if !matches!(dir_ent, Fs::EntriesOption::Entries(_)) {
break 'locked;
}
let mut last_file_hash: bun_watcher::HashType =
bun_watcher::HashType::MAX;

Expand Down
74 changes: 74 additions & 0 deletions src/jsc/virtual_machine_exports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,77 @@ pub fn Bun__setSyntheticAllocationLimitForTesting(
.store(limit, core::sync::atomic::Ordering::Relaxed);
Ok(JSValue::js_number(prev as f64))
}

/// Testing-only: report the live entry count of per-VM caches that the
/// `--hot` reload path can grow. Used by tests to assert that a reload loop
/// does not accumulate entries in these tables.
Comment thread
robobun marked this conversation as resolved.
#[crate::host_fn(export = "Bun__hotReloadDiagnostics")]
pub fn Bun__hotReloadDiagnostics(global: &JSGlobalObject, _frame: &CallFrame) -> JsResult<JSValue> {
let vm = VirtualMachine::get().as_mut();
let obj = JSValue::create_empty_object(global, 5);
obj.put(
global,
b"refStrings",
JSValue::js_number(vm.ref_strings.len() as f64),
);
obj.put(
global,
b"sourceMappings",
JSValue::js_number(vm.saved_source_map_table.len() as f64),
);
obj.put(
global,
b"resolvedPathDups",
JSValue::js_number(vm.resolved_path_dups.len() as f64),
);
let watchlist_len = if vm.bun_watcher.is_null() {
0
} else {
// SAFETY: `bun_watcher` is the live `*mut ImportWatcher` on the JS
// thread; read-only snapshot of the watchlist length under its mutex.
unsafe {
match &*vm.bun_watcher {
crate::hot_reloader::ImportWatcher::Hot(w)
| crate::hot_reloader::ImportWatcher::Watch(w) => {
let _g = w.mutex.lock_guard();
w.watchlist.len()
}
crate::hot_reloader::ImportWatcher::None => 0,
}
}
};
obj.put(
global,
b"watchlistLen",
JSValue::js_number(watchlist_len as f64),
);
obj.put(
global,
b"hotReloadCounter",
JSValue::js_number(f64::from(vm.hot_reload_counter)),
);
#[cfg(bun_track_alloc)]
{
unsafe extern "C" {
fn Bun__trackedAllocHistogram(out: *mut i64, out_len: usize) -> usize;
}
let mut buf = [0i64; 64];
// SAFETY: buf.len() == 64 i64s; callee writes at most that many.
let n = unsafe { Bun__trackedAllocHistogram(buf.as_mut_ptr(), buf.len()) };
let arr = JSValue::create_empty_array(global, 0)?;
let mut total = 0i64;
for i in 0..n {
let bytes = buf[i * 2];
let count = buf[i * 2 + 1];
total += bytes;
let e = JSValue::create_empty_object(global, 3);
e.put(global, b"bucket", JSValue::js_number(i as f64));
e.put(global, b"bytes", JSValue::js_number(bytes as f64));
e.put(global, b"count", JSValue::js_number(count as f64));
arr.push(global, e)?;
}
obj.put(global, b"allocHistogram", arr);
obj.put(global, b"allocLiveBytes", JSValue::js_number(total as f64));
}
Ok(obj)
}
20 changes: 6 additions & 14 deletions src/resolver/dir_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,20 +242,12 @@ impl DirInfo {
/// read-only call sites (`.get()`, `.fd`, iteration). The `DirEntry` is a
/// slot in the BSSMap-backed `EntriesOptionMap` singleton (ARENA — process
/// lifetime), so a `&'static` reborrow of the `&'static mut` returned by
/// `entries_at` is sound and needs no `unsafe` here. Prefer this over
/// `get_entries` + per-site raw deref whenever the caller only reads.
pub(crate) fn get_entries_ref(&self, generation: Generation) -> Option<&'static fs::DirEntry> {
let entries_ptr = fs::FileSystem::instance()
.fs
.entries_at(self.entries, generation)?;
match entries_ptr {
fs::EntriesOption::Entries(entries) => Some(&**entries),
fs::EntriesOption::Err(_) => None,
}
}

/// [`get_entries_ref`](Self::get_entries_ref) for call sites that already
/// hold `entries_mutex` (the mutex is non-recursive); see
/// `entries_at_locked` is sound and needs no `unsafe` here.
///
/// Callers must hold `entries_mutex` for the lookup AND for every
/// subsequent `.data` read on the returned `&DirEntry`: another thread can
/// refresh a stale/older-generation slot in place (which frees the
/// hashbrown bucket array) under that lock. See
Comment thread
robobun marked this conversation as resolved.
/// [`RealFS::entries_at_locked`](fs::RealFS::entries_at_locked).
pub(crate) fn get_entries_ref_locked(
&self,
Expand Down
5 changes: 5 additions & 0 deletions src/resolver/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,10 @@ pub struct DirEntry {
pub dir: &'static [u8],
pub fd: Fd,
pub(crate) generation: Generation,
/// Set by `RealFS::bust_entries_cache`; forces the next locked directory
/// read to re-scan this slot in place (reusing the allocation and `fd`)
/// instead of orphaning it.
Comment thread
robobun marked this conversation as resolved.
pub stale: bool,
pub data: dir_entry::EntryMap,
}

Expand All @@ -397,6 +401,7 @@ impl DirEntry {
dir,
data: dir_entry::EntryMap::default(),
generation,
stale: false,
fd: Fd::INVALID,
}
}
Expand Down
Loading