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
11 changes: 11 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,17 @@ export const lsanDoLeakCheck = $newCppFunction("InternalForTesting.cpp", "jsFunc

export const isASANEnabled: () => boolean = $newCppFunction("InternalForTesting.cpp", "jsFunction_isASANEnabled", 0);

/**
* Leaks one malloc'd block (ASAN builds only; a no-op elsewhere). LSan attributes it
* to `jsFunction_lsanIntentionalLeak`. Used to check that test/leaksan.supp does not
* hide leaks made by the JS context calling it.
*/
export const lsanIntentionalLeak: () => void = $newCppFunction(
"InternalForTesting.cpp",
"jsFunction_lsanIntentionalLeak",
0,
);

export const BunString_toThreadSafeRefCountDelta: () => number = $newCppFunction(
"InternalForTesting.cpp",
"jsFunction_BunString_toThreadSafeRefCountDelta",
Expand Down
5 changes: 3 additions & 2 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2160,8 +2160,9 @@ pub struct RuntimeHooks {
/// right after `close_all_socket_groups`.
pub stop_dns_for_vm_teardown: fn() -> SweepResult,
/// Stop every registered native handle behind a JS object (servers,
/// listeners, fs watchers) — the stop phase for Rust-side JS classes, run
/// with the VM alive right after the WebCore stop phase.
/// listeners, fs watchers) plus the per-VM `process.on("memoryPressure")`
/// watcher — the stop phase for Rust-side JS classes, run with the VM
/// alive right after the WebCore stop phase.
///
/// # Safety
/// `vm` is the live per-thread VM on the JS thread; the JSC heap is alive.
Expand Down
17 changes: 17 additions & 0 deletions src/jsc/bindings/InternalForTesting.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_isASANEnabled, (JSC::JSGlobalObject * global
#endif
}

// Leaks one malloc'd block on purpose so a test can check that LSan, with
// test/leaksan.supp applied, still reports a leak made from the calling JS
// context (test/internal/leaksan-suppressions.test.ts). The report attributes
// the block to this frame. The pointer must not end up anywhere LSan treats
// as a root, so it only ever lives in this local, which is cleared before
// returning; `volatile` keeps the unused allocation from being optimized out.
JSC_DEFINE_HOST_FUNCTION(jsFunction_lsanIntentionalLeak, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
#if ASAN_ENABLED
char* volatile block = static_cast<char*>(malloc(64));
if (block)
block[0] = 1;
block = nullptr;
#endif
return encodedJSUndefined();
}

// Returns the net refcount change on the *original* StringImpl after a
// BunString owning one ref to it is passed through BunString__toThreadSafe
// and then released. A correct implementation must return 0; a positive
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/InternalForTesting.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ JSC_DECLARE_HOST_FUNCTION(jsFunction_arrayBufferViewHasBuffer);
JSC_DECLARE_HOST_FUNCTION(jsFunction_hasReifiedStatic);
JSC_DECLARE_HOST_FUNCTION(jsFunction_lsanDoLeakCheck);
JSC_DECLARE_HOST_FUNCTION(jsFunction_isASANEnabled);
JSC_DECLARE_HOST_FUNCTION(jsFunction_lsanIntentionalLeak);
JSC_DECLARE_HOST_FUNCTION(jsFunction_BunString_toThreadSafeRefCountDelta);
JSC_DECLARE_HOST_FUNCTION(jsFunction_lowercaseHeaderNameSIMD);
JSC_DECLARE_HOST_FUNCTION(jsFunction_emitMemoryPressure);
Expand Down
13 changes: 4 additions & 9 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -565,15 +565,10 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client,
vm.setOnComputeErrorInfo(computeErrorInfoWrapperToString);
vm.setOnComputeErrorInfoJSValue(computeErrorInfoWrapperToJSValue);
vm.setComputeLineColumnWithSourcemap(computeLineColumnWithSourcemap);
vm.setOnEachMicrotaskTick([](JSC::VM& vm) -> void {
// if you process.nextTick on a microtask we need this
auto* globalObject = defaultGlobalObject();
if (auto queue = globalObject->m_nextTickQueue.get()) {
globalObject->resetOnEachMicrotaskTick();
queue->drain(vm, globalObject);
return;
}
});
// A named function, not a lambda: microtasks (module evaluation, promise
// jobs) run under this hook's frame, and a lambda's symbol would contain
// this function's name, which test/leaksan.supp suppresses.
vm.setOnEachMicrotaskTick(&checkIfNextTickWasCalledDuringMicrotask);

if (executionContextId > -1) {
const auto initializeWorker = [&](WebCore::WorkerMessagingProxy& worker) -> void {
Expand Down
7 changes: 7 additions & 0 deletions src/jsc/bindings/napi.h
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,13 @@ struct NapiEnv : public WTF::RefCounted<NapiEnv> {
instanceDataFinalizer.call(this, instanceData, true);
instanceDataFinalizer.clear();
clearExceptionsBetweenFinalizers();

// Same reason as m_cleanupHooks above: a napi_ref the addon never
// deleted keeps the last Ref to this env, so ~NapiEnv may never run.
// Nothing uses the env after cleanup (node_api_get_module_file_name
// answers "" for a null filename).
delete[] std::exchange(filename, nullptr);
m_napiModule.nm_filename = nullptr;
}

// Threadsafe-function registry. Entries are raw ThreadSafeFunction* owned
Expand Down
9 changes: 4 additions & 5 deletions src/jsc/bindings/ncrypto.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ BIOPointer ExportPublicKey(const char* input, size_t length)
return bio;
}

Buffer<char> ExportChallenge(const char* input, size_t length)
DataPointer ExportChallenge(const char* input, size_t length)
{
#ifdef OPENSSL_IS_BORINGSSL
// OpenSSL uses EVP_DecodeBlock, which explicitly removes trailing characters,
Expand All @@ -731,13 +731,12 @@ Buffer<char> ExportChallenge(const char* input, size_t length)
NetscapeSPKIPointer sp(NETSCAPE_SPKI_b64_decode(input, length));
if (!sp) return {};

// ASN1_STRING_to_UTF8 hands back an OPENSSL_malloc'd buffer, which is what
// DataPointer frees.
unsigned char* buf = nullptr;
int buf_size = ASN1_STRING_to_UTF8(&buf, sp->spkac->challenge);
if (buf_size >= 0) {
return {
.data = reinterpret_cast<char*>(buf),
.len = static_cast<size_t>(buf_size),
};
return DataPointer(buf, static_cast<size_t>(buf_size));
}

return {};
Expand Down
3 changes: 1 addition & 2 deletions src/jsc/bindings/ncrypto.h
Original file line number Diff line number Diff line change
Expand Up @@ -1408,8 +1408,7 @@ bool SafeX509InfoAccessPrint(const BIOPointer& out, X509_EXTENSION* ext);
bool VerifySpkac(const char* input, size_t length);
BIOPointer ExportPublicKey(const char* input, size_t length);

// The caller takes ownership of the returned Buffer<char>
Buffer<char> ExportChallenge(const char* input, size_t length);
DataPointer ExportChallenge(const char* input, size_t length);

// ============================================================================
// KDF
Expand Down
8 changes: 4 additions & 4 deletions src/jsc/bindings/node/crypto/node_crypto_binding.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -187,17 +187,17 @@ JSC_DEFINE_HOST_FUNCTION(jsCertExportChallenge, (JSC::JSGlobalObject * lexicalGl
return Bun::ERR::OUT_OF_RANGE(scope, lexicalGlobalObject, "spkac"_s, 0, std::numeric_limits<int32_t>().max(), jsNumber(buffer.size()));
}

auto cert = ncrypto::ExportChallenge(reinterpret_cast<const char*>(buffer.data()), buffer.size());
if (!cert.data || cert.len == 0) {
auto challenge = ncrypto::ExportChallenge(reinterpret_cast<const char*>(buffer.data()), buffer.size());
if (!challenge || challenge.size() == 0) {
return JSValue::encode(jsEmptyString(vm));
}

auto result = JSC::ArrayBuffer::tryCreate({ reinterpret_cast<const uint8_t*>(cert.data), cert.len });
auto result = JSC::ArrayBuffer::tryCreate(challenge.span());
if (!result) {
return JSValue::encode(jsEmptyString(vm));
}

auto* bufferResult = JSC::JSUint8Array::create(lexicalGlobalObject, static_cast<Zig::GlobalObject*>(lexicalGlobalObject)->JSBufferSubclassStructure(), WTF::move(result), 0, cert.len);
auto* bufferResult = JSC::JSUint8Array::create(lexicalGlobalObject, static_cast<Zig::GlobalObject*>(lexicalGlobalObject)->JSBufferSubclassStructure(), WTF::move(result), 0, challenge.size());
RETURN_IF_EXCEPTION(scope, {});

return JSValue::encode(bufferResult);
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/jsc_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1743,7 +1743,9 @@ pub(crate) fn stop_active_handles_for_test_isolation(vm: &mut VirtualMachine) {
}

pub(crate) fn stop_active_handles_for_vm_teardown(vm: &mut VirtualMachine) -> SweepResult {
stop_active_handles(vm, StopReason::VmTeardown)
let result = stop_active_handles(vm, StopReason::VmTeardown);
// Not in the registry: one per VM, armed by process.on("memoryPressure").
result.and(crate::node::memory_pressure::stop_for_vm_teardown(vm))
}

#[derive(Clone, Copy, PartialEq, Eq)]
Expand Down
42 changes: 33 additions & 9 deletions src/runtime/node/memory_pressure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@
//!
//! Armed lazily on the first listener and disarmed on the last removal via
//! `onDidChangeListeners` in `BunProcess.cpp`, matching how signal handlers
//! are wired. The watcher does not keep the event loop alive.
//! are wired. The watcher does not keep the event loop alive. A listener that
//! is still registered when its VM goes away is disarmed by
//! [`stop_for_vm_teardown`] instead.

use bun_event_loop::ConcurrentTask::{Task, task_tag};
use bun_jsc::JSGlobalObject;
#[cfg(not(windows))]
use bun_jsc::virtual_machine::VirtualMachine;
use bun_jsc::virtual_machine::{SweepResult, VirtualMachine};
#[cfg(not(windows))]
use core::ptr::NonNull;

Expand Down Expand Up @@ -215,8 +216,8 @@ mod posix {
*slot(global.bun_vm().as_mut()) = NonNull::new(bun_core::heap::into_raw(watcher).cast());
}

pub(super) fn uninstall(global: &JSGlobalObject) {
let Some(watcher) = take_watcher(global.bun_vm().as_mut()) else {
pub(super) fn uninstall(vm: &mut VirtualMachine) {
let Some(watcher) = take_watcher(vm) else {
return;
};
if let Some(mut poll) = watcher.poll {
Expand Down Expand Up @@ -377,8 +378,8 @@ mod windows {
*slot(global.bun_vm().as_mut()) = NonNull::new(bun_core::heap::into_raw(watcher).cast());
}

pub(super) fn uninstall(global: &JSGlobalObject) {
let Some(raw) = slot(global.bun_vm().as_mut()).take() else {
pub(super) fn uninstall(vm: &mut VirtualMachine) {
let Some(raw) = slot(vm).take() else {
return;
};
// SAFETY: slot is populated only by `install` with a `Box<MemoryPressureWatcher>`.
Expand Down Expand Up @@ -406,10 +407,33 @@ pub(crate) extern "C" fn Bun__MemoryPressure__install(global: &JSGlobalObject) {

#[unsafe(no_mangle)]
pub(crate) extern "C" fn Bun__MemoryPressure__uninstall(global: &JSGlobalObject) {
uninstall(global.bun_vm().as_mut());
}

fn uninstall(vm: &mut VirtualMachine) {
#[cfg(not(windows))]
posix::uninstall(global);
posix::uninstall(vm);
#[cfg(windows)]
windows::uninstall(global);
windows::uninstall(vm);
}

/// Stop-phase teardown (`stop_active_handles_for_vm_teardown`): a listener
/// still registered when the VM exits never reaches `uninstall` through
/// `onDidChangeListeners`, and the slot in `RareData` is an erased pointer
/// that `RareData`'s drop does not free. Runs while `RareData.file_polls` and
/// the loop are alive, which `uninstall` needs to unregister the poll.
pub(crate) fn stop_for_vm_teardown(vm: &mut VirtualMachine) -> SweepResult {
// Read the raw option: a VM that never armed the watcher has nothing to
// stop and must not lazily allocate a `RareData` here.
let installed = vm
.rare_data
.as_deref_mut()
.is_some_and(|rare| rare.memory_pressure_watcher_slot().is_some());
if !installed {
return SweepResult::Idle;
}
uninstall(vm);
SweepResult::Stopped
}

#[unsafe(no_mangle)]
Expand Down
19 changes: 17 additions & 2 deletions src/uws_sys/Loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,19 @@ impl PosixLoop {
/// tick; at process/Worker teardown the loop has stopped, so
/// `closeAllSocketGroups()` must drain it explicitly or every just-closed
/// `us_socket_t` (libc-allocated) shows up as an LSAN leak.
///
/// Teardown only. A connecting socket closed while its DNS result was
/// already queued on `dns_ready_head` (`us_connecting_socket_close` could
/// not cancel it) is only released by `us_internal_socket_after_resolve`,
/// which the next tick would have run; run that queue first so those land
/// on `closed_connecting_head` too. Every entry is closed by now, so this
/// frees and never starts a connection.
pub fn drain_closed_sockets(&mut self) {
// SAFETY: self is a valid loop pointer
unsafe { c::us_internal_free_closed_sockets(self) };
unsafe {
c::us_internal_handle_dns_results(self);
c::us_internal_free_closed_sockets(self);
}
}

/// `us_socket_group_close_all()` on every group currently linked to this
Expand Down Expand Up @@ -521,9 +531,13 @@ impl WindowsLoop {
self.dec();
}

/// See `PosixLoop::drain_closed_sockets`.
pub fn drain_closed_sockets(&mut self) {
// SAFETY: self is a valid loop pointer
unsafe { c::us_internal_free_closed_sockets(self) };
unsafe {
c::us_internal_handle_dns_results(self);
c::us_internal_free_closed_sockets(self);
}
}

pub fn close_all_groups(&mut self) -> bool {
Expand Down Expand Up @@ -633,6 +647,7 @@ mod c {
now_ns: u64,
);
pub(super) fn us_internal_free_closed_sockets(loop_: *mut Loop);
pub(super) fn us_internal_handle_dns_results(loop_: *mut Loop) -> c_int;
pub(super) fn us_loop_close_all_groups(loop_: *mut Loop) -> c_int;
#[cfg(not(windows))]
pub(super) safe fn uws_get_loop() -> *mut Loop;
Expand Down
107 changes: 107 additions & 0 deletions test/internal/leaksan-suppressions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// CI runs the ASAN build with LeakSanitizer on and test/leaksan.supp applied
// (scripts/runner.node.mjs). An LSan suppression matches when its pattern is a
// substring of ANY frame of an allocation's stack, so an entry naming a frame
// that is on the stack while ordinary code runs hides every leak that code
// makes. The frames that run a module's top level and the VM's microtask-tick
// hook are the important cases: everything a fixture does at its top level
// (-e script, CJS or ESM entry, require()d or imported module, worker eval
// script, the top level of a test file) or from a nextTick/promise
// continuation runs under one of them.
//
// Each row below leaks one malloc'd block (lsanIntentionalLeak, attributed to
// jsFunction_lsanIntentionalLeak) from one of those contexts and expects LSan,
// with the real suppressions file, to report it. The setImmediate row is the
// control: neither kind of frame is on its stack.
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isASAN, isLinux, tempDir } from "harness";
import { join } from "node:path";

const suppressions = join(import.meta.dir, "..", "leaksan.supp");
const leakCJS = `require("bun:internal-for-testing").lsanIntentionalLeak();`;
const leakESM = `import { lsanIntentionalLeak } from "bun:internal-for-testing";\nlsanIntentionalLeak();`;

type Row = {
name: string;
files: Record<string, string>;
cmd: string[];
};

const ROWS: Row[] = [
{ name: "bun -e script", files: {}, cmd: ["-e", leakCJS] },
{ name: "CommonJS entry point", files: { "entry.cjs": leakCJS }, cmd: ["entry.cjs"] },
{ name: "ES module entry point", files: { "entry.mjs": leakESM }, cmd: ["entry.mjs"] },
{
name: "require()d CommonJS module",
files: { "entry.cjs": `require("./dep.cjs");`, "dep.cjs": leakCJS },
cmd: ["entry.cjs"],
},
{
name: "imported ES module",
files: { "entry.mjs": `import "./dep.mjs";`, "dep.mjs": leakESM },
cmd: ["entry.mjs"],
},
{
name: "worker_threads eval script",
files: {
"entry.cjs": `new (require("node:worker_threads").Worker)(${JSON.stringify(leakCJS)}, { eval: true });`,
},
cmd: ["entry.cjs"],
},
{
name: "top level of a bun test file",
files: { "leak.test.js": `${leakCJS}\nrequire("bun:test").test("registered", () => {});` },
cmd: ["test", "leak.test.js"],
},
// Microtasks and nextTick callbacks run under the VM's microtask-tick hook.
{
name: "process.nextTick callback",
files: { "entry.cjs": `process.nextTick(() => { ${leakCJS} });` },
cmd: ["entry.cjs"],
},
{
name: "await continuation",
files: { "entry.mjs": `await Promise.resolve();\n${leakESM}` },
cmd: ["entry.mjs"],
},
{
name: "setImmediate callback (control)",
files: { "entry.cjs": `setImmediate(() => { ${leakCJS} });` },
cmd: ["entry.cjs"],
},
];

describe.skipIf(!isASAN || !isLinux)("test/leaksan.supp does not hide leaks made by the code under test", () => {
for (const row of ROWS) {
test.concurrent(
row.name,
async () => {
using dir = tempDir("leaksan-supp", row.files);
await using proc = Bun.spawn({
cmd: [bunExe(), ...row.cmd],
cwd: String(dir),
env: {
...bunEnv,
BUN_DESTRUCT_VM_ON_EXIT: "1",
ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"),
// Same stack depth as CI; a suppression can only match a frame LSan recorded.
// exitcode/abort_on_error pin down how a detected leak ends the process so
// the assertion below does not depend on the ASAN_OPTIONS inherited from CI.
LSAN_OPTIONS: `malloc_context_size=30:print_suppressions=0:abort_on_error=0:exitcode=23:suppressions=${suppressions}`,
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const reported =
stderr.includes("LeakSanitizer: detected memory leaks") && stderr.includes("jsFunction_lsanIntentionalLeak");
expect({ reported, exitCode, detail: reported && exitCode === 23 ? "" : stdout + stderr }).toEqual({
reported: true,
exitCode: 23,
detail: "",
});
},
// Symbolizing the report dominates: tens of seconds on a loaded machine with the debug binary.
90_000,
);
}
});
Loading
Loading