Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8c373a0
Make the full Node parallel/sequential suite pass leak-clean under th…
cirospaciari Jun 4, 2026
2aec5c2
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 4, 2026
ab7a099
Merge branch 'main' into claude/node-suite-asan-leak-clean
robobun Jun 10, 2026
9b7a19b
boringssl: free SAN stacks with GENERAL_NAMES_free
alii Jul 8, 2026
87ac1ab
url: return OwnedString from WTF::URL getters
alii Jul 8, 2026
671eff8
child_process: read normalized stdio length; add explicit takeStdio
alii Jul 8, 2026
8756b01
vm: consolidate pre-teardown Strong-handle release; call from Worker …
alii Jul 8, 2026
b340e97
test: narrow leaksan suppressions; scope FLAKY entry to ASAN; runner …
alii Jul 8, 2026
1b7f0fb
Merge branch 'main' into claude/node-suite-asan-leak-clean
alii Jul 8, 2026
89c5a16
Merge branch 'main' into claude/node-suite-asan-leak-clean
alii Jul 8, 2026
c9aaff8
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 8, 2026
3df81f7
Merge branch 'main' into claude/node-suite-asan-leak-clean
alii Jul 9, 2026
d538d79
runner: keep NODE_TEST_DIR unset on Windows
alii Jul 9, 2026
10af51d
Merge remote-tracking branch 'origin/main' into HEAD
alii Jul 10, 2026
f04711d
verify skill: use bun bd for probes to match CLAUDE.md build-then-exe…
alii Jul 10, 2026
151cf3c
Merge remote-tracking branch 'origin/main' into claude/node-suite-asa…
cirospaciari Jul 14, 2026
a0d36f9
test: unquarantine test-worker-terminate-http2-respond-with-file
cirospaciari Jul 14, 2026
e06fa0a
vm: release Strong handles in destroy() too; strengthen child_process…
cirospaciari Jul 14, 2026
f7bd302
test: drop the Bun.main teardown smoke test
cirospaciari Jul 14, 2026
b16da77
Merge origin/main into claude/node-suite-asan-leak-clean
cirospaciari Jul 14, 2026
a9612fb
Merge remote-tracking branch 'origin/main' into claude/node-suite-asa…
robobun Aug 3, 2026
6686a7f
trim comments to <=3 lines, cite spec/node source
robobun Aug 3, 2026
703ab06
test: await stream finished() instead of asserting readableEnded at exit
robobun Aug 4, 2026
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
16 changes: 16 additions & 0 deletions scripts/runner.node.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function getNodeParallelTestTimeout(testPath) {
if (testPath.includes("test-cluster-")) return 60_000; // cluster IPC + socket-handle passing is process-heavy under runner concurrency
if (testPath.includes("-docker-")) return 60_000;
if (testPath.includes("test-stdin-pipe-large")) return 60_000; // pipes 1MB stdin->stdout through an extra child process; slow under runner concurrency
if (testPath.includes("test-require-builtins")) return 120_000; // requires every builtin module; ~60s alone under local ASAN debug builds
// test-fs-read-stream-pos.js exit condition is a pure timing race (writer must append
// between two consecutive ReadStream preads) with a 90s upstream safety timer; solo
// runtimes are ~1s on linux-x64 but 1-40s on Windows since #34834 raised its timer
Expand Down Expand Up @@ -896,6 +897,15 @@ async function runTests() {
// (test-child-process-*-detached.js), which this flag defeats.
env.BUN_FEATURE_FLAG_NO_ORPHANS = "1";
}
if (isMacOS && basename(execPath).includes("asan")) {
// ASAN debug builds resolve asan-dyld-shim.dylib via @rpath
// relative to the binary. Tests that copy process.execPath
// elsewhere (fork-exec-path, stdin-from-file-spawn, ...) lose
// that anchor; give dyld a last-resort search path (prepending
// rather than clobbering any inherited value).
const dir = dirname(realpathSync(execPath));
env.DYLD_FALLBACK_LIBRARY_PATH = [dir, process.env.DYLD_FALLBACK_LIBRARY_PATH].filter(Boolean).join(":");
}
if ((basename(execPath).includes("asan") || !isCI) && shouldValidateExceptions(testPath)) {
env.BUN_JSC_validateExceptionChecks = "1";
env.BUN_JSC_dumpSimulatedThrows = "1";
Expand Down Expand Up @@ -1789,6 +1799,12 @@ async function spawnBun(execPath, { args, cwd, timeout, gracefulTimeout, idleTim
BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0",
BUN_INSTALL_CACHE_DIR: tmpdirPath,
SHELLOPTS: isWindows ? "igncr" : undefined, // ignore "\r" on Windows
// common/tmpdir.js reads NODE_TEST_DIR — point it at the per-test tmpdir
// so its `.tmp.<id>` subdir is swept by the finally-rmSync below even
// when the test aborts (ASAN abort_on_error skips its exit handler).
// POSIX-only: there is no Windows ASAN lane, and relocating testRoot to
// realpath(%TEMP%) breaks path-shape assumptions in a few Windows tests.
NODE_TEST_DIR: isWindows ? undefined : tmpdirPath,
TEST_TMPDIR: tmpdirPath, // Used in Node.js tests.
...(typeof remapPort == "number"
? { BUN_CRASH_REPORT_URL: `http://localhost:${remapPort}` }
Expand Down
10 changes: 6 additions & 4 deletions src/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,16 @@ let url = URL::from_utf8(href)?; // Option<NonNull<URL>>
// caller owns the C++ object — destroy it when done:
// unsafe { URL::destroy(url.as_ptr()) }

url.protocol() // bun_core::String
url.pathname() // bun_core::String
url.host() // bun_core::String — the hostname WITHOUT the port (opposite of JS `host`!)
url.protocol() // bun_core::OwnedString (+1; Drop derefs)
url.pathname() // bun_core::OwnedString
url.host() // bun_core::OwnedString — the hostname WITHOUT the port (opposite of JS `host`!)
url.port() // u32 (u32::MAX = unset; otherwise u16 range)
```

`URL::href_from_js`, `URL::file_url_from_string`, `URL::path_from_file_url`
do whole-string conversions. The JSC-free shim `bun_url::whatwg::URL` exposes
do whole-string conversions. Every string getter returns `OwnedString` — use
`.into_inner()` only when you must transfer the +1 out (e.g. into a struct
field that will deref later). The JSC-free shim `bun_url::whatwg::URL` exposes
`hostname()`, which returns the host WITH the port (also the opposite of JS
`hostname`) — so `bun_jsc::URL::host` and `bun_url::whatwg::URL::hostname`
are effectively swapped relative to their JS namesakes.
Expand Down
8 changes: 4 additions & 4 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -981,7 +981,7 @@ use bun_boringssl as boringssl;
use bun_collections::{ArrayHashMap, VecExt};
use bun_core::StringBuilder;
use bun_core::{FeatureFlags, Global, Output};
use bun_core::{OwnedString, String as BunString, Tag as BunStringTag, strings};
use bun_core::{String as BunString, Tag as BunStringTag, strings};
use bun_http_types::ETag::StringPointer;
use bun_uws as uws;
// the std Wyhash algorithm, not Wyhash11.
Expand Down Expand Up @@ -5018,7 +5018,7 @@ impl<'a> HTTPClient<'a> {
debug_assert!(string_builder.cap == string_builder.len);

let input = BunString::borrow_utf8(string_builder.allocated_slice());
let normalized_url = OwnedString::new(bun_url::href_from_string(&input));
let normalized_url = bun_url::href_from_string(&input);
if normalized_url.tag() == BunStringTag::Dead {
// URL__getHref failed, dont pass dead tagged string to toOwnedSlice.
return Err(crate::Error::RedirectURLInvalid);
Expand Down Expand Up @@ -5074,7 +5074,7 @@ impl<'a> HTTPClient<'a> {
debug_assert!(string_builder.cap == string_builder.len);

let input = BunString::borrow_utf8(string_builder.allocated_slice());
let normalized_url = OwnedString::new(bun_url::href_from_string(&input));
let normalized_url = bun_url::href_from_string(&input);
if normalized_url.tag() == BunStringTag::Dead {
return Err(crate::Error::RedirectURLInvalid);
}
Expand All @@ -5098,7 +5098,7 @@ impl<'a> HTTPClient<'a> {

let base = BunString::borrow_utf8(original_url.href);
let rel = BunString::borrow_utf8(location);
let new_url_ = OwnedString::new(bun_url::join(&base, &rel));
let new_url_ = bun_url::join(&base, &rel);

if new_url_.is_empty() {
return Err(crate::Error::InvalidRedirectURL);
Expand Down
4 changes: 2 additions & 2 deletions src/install/NetworkTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,10 +466,10 @@ impl NetworkTask {
// `OwnedString` derefs the WTF-backed result on scope exit —
// covers both the
// success path and the InvalidURL early returns below.
let tmp = bun_core::OwnedString::new(bun_url::join(
let tmp = bun_url::join(
&bun_core::String::borrow_utf8(scope.url.href()),
&bun_core::String::borrow_utf8(encoded_name),
));
);

if tmp.tag() == bun_core::Tag::Dead {
if !is_optional {
Expand Down
16 changes: 8 additions & 8 deletions src/install/hosted_git_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ use core::ptr::NonNull;

use bun_alloc::AllocError;
use bun_core::StringBuilder;
use bun_core::{OwnedString, strings};
use bun_core::strings;
use bun_url::PercentEncoding;
use bun_url::whatwg::URL as JscUrl;
use enum_map::{Enum, EnumMap};
Expand Down Expand Up @@ -973,7 +973,7 @@ impl HostProvider {

/// Parse a URL and return the appropriate host provider, if any.
fn from_url(url: &JscUrl) -> Option<HostProvider> {
let proto_str = OwnedString::new(url.protocol());
let proto_str = url.protocol();

// Try shortcut first (github:, gitlab:, etc.)
if let Some(provider) = HostProvider::from_shortcut(proto_str.byte_slice(), false) {
Expand All @@ -985,7 +985,7 @@ impl HostProvider {

/// Given a URL, use the domain in the URL to find the appropriate host provider.
fn from_url_domain(url: &JscUrl) -> Option<HostProvider> {
let hostname_str = OwnedString::new(url.hostname());
let hostname_str = url.hostname();

let hostname_utf8 = hostname_str.to_utf8();
let hostname = strings::without_prefix(hostname_utf8.slice(), b"www.");
Expand Down Expand Up @@ -1074,7 +1074,7 @@ pub(crate) mod formatters {
// valid until it's copied into the StringBuilder.
let fragment_utf8;
let committish: Option<&[u8]> = if type_part.is_none() {
let fragment_str = OwnedString::new(url.fragment_identifier());
let fragment_str = url.fragment_identifier();
fragment_utf8 = fragment_str.to_utf8();
let fragment = fragment_utf8.slice();
if !fragment.is_empty() {
Expand Down Expand Up @@ -1135,7 +1135,7 @@ pub(crate) mod formatters {
return Ok(None);
}

let fragment_str = OwnedString::new(url.fragment_identifier());
let fragment_str = url.fragment_identifier();
let fragment_utf8 = fragment_str.to_utf8();
let fragment = fragment_utf8.slice();
let committish: Option<&[u8]> = if !fragment.is_empty() {
Expand Down Expand Up @@ -1190,7 +1190,7 @@ pub(crate) mod formatters {
return Ok(None);
}

let fragment_str = OwnedString::new(url.fragment_identifier());
let fragment_str = url.fragment_identifier();
let fragment_utf8 = fragment_str.to_utf8();
let committish = fragment_utf8.slice();

Expand Down Expand Up @@ -1255,7 +1255,7 @@ pub(crate) mod formatters {
return Ok(None);
}

let fragment_str = OwnedString::new(url.fragment_identifier());
let fragment_str = url.fragment_identifier();
let fragment_utf8 = fragment_str.to_utf8();
let fragment = fragment_utf8.slice();
let committish: Option<&[u8]> = if !fragment.is_empty() {
Expand Down Expand Up @@ -1332,7 +1332,7 @@ pub(crate) mod formatters {
return Ok(None);
}

let fragment_str = OwnedString::new(url.fragment_identifier());
let fragment_str = url.fragment_identifier();
let fragment_utf8 = fragment_str.to_utf8();
let fragment = fragment_utf8.slice();
let committish: Option<&[u8]> = if !fragment.is_empty() {
Expand Down
18 changes: 6 additions & 12 deletions src/js/node/child_process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1382,8 +1382,6 @@ class ChildProcess extends EventEmitter {

const detachedOption = options.detached;
this.#stdioOptions = bunStdio;
const stdioCount = stdio.length;
const hasSocketsToEagerlyLoad = stdioCount >= 3;

validateString(options.file, "options.file");
var file;
Expand Down Expand Up @@ -1415,12 +1413,10 @@ class ChildProcess extends EventEmitter {
this.pid = this.#handle.pid;
$debug("ChildProcess: onExit", exitCode, signalCode, err, this.pid);

if (hasSocketsToEagerlyLoad) {
process.nextTick(() => {
void this.stdio;
$debug("ChildProcess: onExit", exitCode, signalCode, err, this.pid);
});
}
process.nextTick(() => {
void this.stdio;
$debug("ChildProcess: onExit", exitCode, signalCode, err, this.pid);
});

process.nextTick(
(exitCode, signalCode, err) => this.#handleOnExit(exitCode, signalCode, err),
Expand Down Expand Up @@ -1460,10 +1456,8 @@ class ChildProcess extends EventEmitter {
if (options[kFromNode]) this.#closesNeeded += 1;
}

if (hasSocketsToEagerlyLoad) {
for (let item of this.stdio) {
item?.ref?.();
}
for (let item of this.stdio) {
item?.ref?.();
}
} catch (ex) {
const exCode = ex != null && typeof ex === "object" && Object.hasOwn(ex, "code") ? ex.code : undefined;
Expand Down
51 changes: 22 additions & 29 deletions src/jsc/URL.rs
Original file line number Diff line number Diff line change
@@ -1,47 +1,46 @@
use core::ptr::NonNull;

use bun_core::String;
use bun_core::{OwnedString, String};
use bun_jsc::{JSGlobalObject, JSValue, JsResult};

bun_opaque::opaque_ffi! {
/// Opaque handle to a WebKit `WTF::URL` allocated on the C++ side.
pub struct URL;
}

// Getters take `&URL` (non-null `*const URL` at the C ABI; BunString.cpp never
// mutates the WTF::URL on read). `&mut String` for the in/out params is
// ABI-identical to non-null `*mut String`. `URL__deinit` consumes the C++
// allocation, so it keeps a raw pointer and stays `unsafe fn`.
// Getters take `&URL` (BunString.cpp never mutates on read); `URL__deinit`
// consumes the C++ allocation so it stays `unsafe fn`. String returns are +1
// (`Bun::toStringRef`) → `OwnedString` (repr(transparent)) for scope-exit deref.
unsafe extern "C" {
safe fn URL__fromJS(value: JSValue, global: &JSGlobalObject) -> *mut URL;
safe fn URL__fromString(input: &mut String) -> *mut URL;
safe fn URL__protocol(url: &URL) -> String;
safe fn URL__username(url: &URL) -> String;
safe fn URL__password(url: &URL) -> String;
safe fn URL__host(url: &URL) -> String;
safe fn URL__protocol(url: &URL) -> OwnedString;
safe fn URL__username(url: &URL) -> OwnedString;
safe fn URL__password(url: &URL) -> OwnedString;
safe fn URL__host(url: &URL) -> OwnedString;
safe fn URL__port(url: &URL) -> u32;
fn URL__deinit(url: *mut URL);
safe fn URL__pathname(url: &URL) -> String;
safe fn URL__getHrefFromJS(value: JSValue, global: &JSGlobalObject) -> String;
safe fn URL__getFileURLString(input: &mut String) -> String;
safe fn URL__pathFromFileURL(input: &mut String) -> String;
safe fn URL__pathname(url: &URL) -> OwnedString;
safe fn URL__getHrefFromJS(value: JSValue, global: &JSGlobalObject) -> OwnedString;
safe fn URL__getFileURLString(input: &mut String) -> OwnedString;
safe fn URL__pathFromFileURL(input: &mut String) -> OwnedString;
}

impl URL {
pub fn file_url_from_string(str: String) -> String {
pub fn file_url_from_string(str: String) -> OwnedString {
let mut input = str;
URL__getFileURLString(&mut input)
}

pub fn path_from_file_url(str: String) -> String {
pub fn path_from_file_url(str: String) -> OwnedString {
let mut input = str;
URL__pathFromFileURL(&mut input)
}

/// This percent-encodes the URL, punycode-encodes the hostname, and returns the result
/// If it fails, the tag is marked Dead
#[track_caller]
pub fn href_from_js(value: JSValue, global: &JSGlobalObject) -> JsResult<String> {
pub fn href_from_js(value: JSValue, global: &JSGlobalObject) -> JsResult<OwnedString> {
crate::call_check_slow(global, || URL__getHrefFromJS(value, global))
}

Expand All @@ -61,27 +60,21 @@ impl URL {
// from_js/from_string/from_utf8 return an owned C++ heap pointer that the
// caller must destroy().

pub fn protocol(&self) -> String {
pub fn protocol(&self) -> OwnedString {
URL__protocol(self)
}

pub fn username(&self) -> String {
pub fn username(&self) -> OwnedString {
URL__username(self)
}

pub fn password(&self) -> String {
pub fn password(&self) -> OwnedString {
URL__password(self)
}

/// Returns the host WITHOUT the port.
///
/// Note that this does NOT match JS behavior, which returns the host with the port. The
/// with-port form lives on the JSC-free shim as `bun_url::whatwg::URL::hostname`.
///
/// ```text
/// URL("http://example.com:8080").host() => "example.com"
/// ```
pub fn host(&self) -> String {
/// Host WITHOUT the port — opposite of JS `url.host` (https://url.spec.whatwg.org/#dom-url-host).
/// The with-port form is `bun_url::whatwg::URL::hostname`.
pub fn host(&self) -> OwnedString {
URL__host(self)
}

Expand All @@ -98,7 +91,7 @@ impl URL {
unsafe { URL__deinit(this) }
}

pub fn pathname(&self) -> String {
pub fn pathname(&self) -> OwnedString {
URL__pathname(self)
}
}
30 changes: 25 additions & 5 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1601,9 +1601,7 @@ impl VirtualMachine {
// JSC `Strong`/`Weak` handles against a live heap.
self.event_loop_mut().release_queued_tasks_for_shutdown();

if let Some(rare) = self.rare_data.as_deref_mut() {
rare.release_js_handles();
}
self.release_strong_refs_before_teardown();

Zig__GlobalObject__destructOnExit(self.global());

Expand All @@ -1619,6 +1617,22 @@ impl VirtualMachine {
}
bun_core::Global::exit(u32::from(self.exit_handler.exit_code))
}

/// Release every Rust-side JSC `Strong` (fields, `RareData`, `RuntimeState`)
/// while the HandleSet is live — dropping after `destructOnExit`/teardownJSCVM
/// is an ASAN UAF in `Bun__StrongRef__delete`. Idempotent.
pub fn release_strong_refs_before_teardown(&mut self) {
self.overridden_main.deinit();
self.entry_point_result.value.deinit();
if let Some(rare) = self.rare_data.as_deref_mut() {
rare.release_js_handles();
}
if let Some(hooks) = runtime_hooks() {
// SAFETY: JS thread, live VM; the hook only touches the
// per-thread RuntimeState it owns.
unsafe { (hooks.release_runtime_state_js_handles)(core::ptr::from_mut(self)) };
}
}
}

extern crate alloc;
Expand Down Expand Up @@ -1659,6 +1673,9 @@ pub struct RuntimeHooks {
/// `heap::take`s it and clears its thread-local cache. Without this slot
/// every worker leaked one box.
pub deinit_runtime_state: unsafe fn(vm: *mut VirtualMachine, state: RuntimeState),
/// Release `RuntimeState`'s JSC `Strong` handles (SQL on_query callbacks)
/// before `destructOnExit` — dropping later UAFs the freed HandleSet.
pub release_runtime_state_js_handles: unsafe fn(vm: *mut VirtualMachine),
/// `ServerEntryPoint.generate(watch, entry_path)` — produces the synthetic
/// `bun:main` module body for `entry_path`. Returns `false` on error
/// (error already logged into `vm.log`).
Expand Down Expand Up @@ -4315,6 +4332,11 @@ impl VirtualMachine {
}
/// Worker-thread teardown.
pub fn destroy(&mut self) {
// No-op on `global_exit`/worker paths (already released, idempotent);
// `bake::production`'s unwind guard reaches here with the JSC VM still
// live and no prior release, so this is its reclaim point.
self.release_strong_refs_before_teardown();

self.regular_event_loop.deinit();
self.macro_event_loop.deinit();

Expand Down Expand Up @@ -4370,8 +4392,6 @@ impl VirtualMachine {

drop(core::mem::take(&mut self.resolved_path_dups));

self.overridden_main.deinit();

// `timer`/`entry_point` live in the high-tier `RuntimeState` box, so
// dispatch the reclaim through the hook.
if let Some(hooks) = runtime_hooks() {
Expand Down
3 changes: 3 additions & 0 deletions src/jsc/bindings/ConsoleObject.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ using namespace JSC;

class ConsoleObject final : public JSC::ConsoleClient {
WTF_DEPRECATED_MAKE_FAST_ALLOCATED(ConsoleObject);
// FAST_ALLOCATED shadows CanMakeThreadSafeCheckedPtr's destroying-delete,
// so redeclare it (matches JSC::JSGlobalObjectConsoleClient).
WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR(ConsoleObject);

public:
~ConsoleObject() final {}
Expand Down
Loading