Skip to content
Open
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
1 change: 1 addition & 0 deletions src/bun_core/env_var.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ pub mod feature_flag {
new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG, "BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG", {});
new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER, "BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER", {});
new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE, "BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE", {});
new_feature_flag!(pub BUN_FEATURE_FLAG_EXPERIMENTAL_ISOLATION_GLOBAL_REUSE, "BUN_FEATURE_FLAG_EXPERIMENTAL_ISOLATION_GLOBAL_REUSE", {});
new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE", {});
new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO", {});
// Force the event loop to use epoll_pwait(2) instead of epoll_pwait2(2).
Expand Down
6 changes: 6 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,12 @@ export const dnsCacheSeed = $newRustFunction("runtime/dns_jsc/dns.rs", "internal
addresses: string[],
) => number[];

export const testIsolationResetStats = $newCppFunction(
"InternalForTesting.cpp",
"jsFunction_testIsolationResetStats",
0,
) as () => { reuse: number; swap: number };

export const fetchH2Internals = {
liveCounts: $newRustFunction("http/H2Client.rs", "TestingAPIs.liveCounts", 0) as () => {
sessions: number;
Expand Down
10 changes: 10 additions & 0 deletions src/jsc/JSGlobalObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,14 @@ impl JSGlobalObject {
Zig__GlobalObject__createForTestIsolation(old_global, console)
}

pub(crate) fn capture_test_isolation_baseline(global: &JSGlobalObject) {
Zig__GlobalObject__captureTestIsolationBaseline(global)
}

pub(crate) fn try_reset_for_test_isolation(global: &JSGlobalObject) -> bool {
Zig__GlobalObject__tryResetForTestIsolation(global)
}

pub fn report_uncaught_exception_from_error(&self, proof: JsError) {
crate::mark_binding();
let exc = self
Expand Down Expand Up @@ -1649,6 +1657,8 @@ unsafe extern "C" {
old_global: &JSGlobalObject,
console: *mut c_void,
) -> *mut JSGlobalObject;
safe fn Zig__GlobalObject__captureTestIsolationBaseline(global: &JSGlobalObject);
safe fn Zig__GlobalObject__tryResetForTestIsolation(global: &JSGlobalObject) -> bool;
}

impl ScriptExecutionContextIdentifier {
Expand Down
25 changes: 21 additions & 4 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ pub struct VirtualMachine {
#[derive(Default)]
pub struct TestIsolationState {
pub saved_cwd: Option<Box<[u8]>>,
/// Cleared on every full swap so the next file re-captures its baseline.
pub baseline_captured: bool,
pub global_reuse: bool,
}

// ──────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -4454,6 +4457,11 @@ impl VirtualMachine {
}
}

if self.test_isolation_state.global_reuse && !self.test_isolation_state.baseline_captured {
JSGlobalObject::capture_test_isolation_baseline(self.global());
self.test_isolation_state.baseline_captured = true;
}

// Note: reshaped for borrowck.
let global = self.global;
let main_str = bun_core::String::from_bytes(self.main());
Expand Down Expand Up @@ -4690,13 +4698,22 @@ impl VirtualMachine {
self.unhandled_error_counter = 0;

let old_global = self.global;
let old_global_ref = JSGlobalObject::opaque_ref(old_global);

// Scrub and reuse the global if the file left it in its post-preload
// shape; node_modules CodeBlocks and JIT'd code then survive.
Comment thread
robobun marked this conversation as resolved.
Outdated
if self.test_isolation_state.global_reuse
&& JSGlobalObject::try_reset_for_test_isolation(old_global_ref)
{
return;
}
Comment thread
robobun marked this conversation as resolved.

// `old_global` valid for VM lifetime (safe ZST-handle deref);
// `console` is the live per-VM ConsoleObject.
let new_global: *mut JSGlobalObject = JSGlobalObject::create_for_test_isolation(
JSGlobalObject::opaque_ref(old_global),
self.console.cast(),
);
let new_global: *mut JSGlobalObject =
JSGlobalObject::create_for_test_isolation(old_global_ref, self.console.cast());
self.global = new_global;
self.test_isolation_state.baseline_captured = false;
VMHolder::set_cached_global_object(Some(new_global));
self.regular_event_loop.global = NonNull::new(new_global);
self.macro_event_loop.global = NonNull::new(new_global);
Expand Down
7 changes: 7 additions & 0 deletions src/jsc/bindings/BunClientData.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class GlobalObject;

namespace Bun {
class StrongRootBlock;
struct TestIsolationBaseline;
}

namespace WebCore {
Expand Down Expand Up @@ -141,6 +142,12 @@ class JSVMClientData : public JSC::VM::ClientData {
// after every swap.
WTF::UncheckedKeyHashMap<WTF::String, RefPtr<JSC::SourceProvider>> isolationSourceProviderCache;

// See Zig__GlobalObject__captureTestIsolationBaseline (ZigGlobalObject.cpp).
struct TestIsolationBaselineDeleter {
void operator()(Bun::TestIsolationBaseline*) const;
};
std::unique_ptr<Bun::TestIsolationBaseline, TestIsolationBaselineDeleter> testIsolationBaseline;

private:
bool isWebCoreJSClientData() const final { return true; }

Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/BunProcess.h
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class Process : public WebCore::JSEventEmitter {

JSString* cachedCwd() { return m_cachedCwd.get(); }
void setCachedCwd(JSC::VM& vm, JSString* cwd) { m_cachedCwd.set(vm, this, cwd); }
void clearCachedCwd() { m_cachedCwd.clear(); }

JSValue getArgv(JSGlobalObject* globalObject);
void setArgv(JSGlobalObject* globalObject, JSValue argv);
Expand Down
12 changes: 12 additions & 0 deletions src/jsc/bindings/InternalForTesting.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#endif

extern "C" void BunString__toThreadSafe(BunString* str);
extern "C" void Zig__GlobalObject__testIsolationResetStats(Zig::GlobalObject*, uint32_t*, uint32_t*);

namespace Bun {

Expand All @@ -31,6 +32,17 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_lowercaseHeaderNameSIMD, (JSC::JSGlobalObjec
return JSC::JSValue::encode(JSC::jsString(vm, WebCore::lowercaseHeaderName(string)));
}

JSC_DEFINE_HOST_FUNCTION(jsFunction_testIsolationResetStats, (JSC::JSGlobalObject * globalObject, JSC::CallFrame*))
{
auto& vm = globalObject->vm();
uint32_t reuse = 0, swap = 0;
Zig__GlobalObject__testIsolationResetStats(defaultGlobalObject(globalObject), &reuse, &swap);
auto* obj = JSC::constructEmptyObject(globalObject);
obj->putDirect(vm, Identifier::fromString(vm, "reuse"_s), jsNumber(reuse));
obj->putDirect(vm, Identifier::fromString(vm, "swap"_s), jsNumber(swap));
return JSValue::encode(obj);
}

JSC_DEFINE_HOST_FUNCTION(jsFunction_arrayBufferViewHasBuffer, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
auto value = callFrame->argument(0);
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 @@ -13,5 +13,6 @@ JSC_DECLARE_HOST_FUNCTION(jsFunction_BunString_toThreadSafeRefCountDelta);
JSC_DECLARE_HOST_FUNCTION(jsFunction_lowercaseHeaderNameSIMD);
JSC_DECLARE_HOST_FUNCTION(jsFunction_emitMemoryPressure);
JSC_DECLARE_HOST_FUNCTION(jsFunction_isMemoryPressureWatcherInstalled);
JSC_DECLARE_HOST_FUNCTION(jsFunction_testIsolationResetStats);

}
195 changes: 195 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,201 @@
return globalObject;
}

namespace Bun {

// Snapshot of a fresh global (post --preload) for tryResetForTestIsolation.
struct TestIsolationBaseline {
WTF_DEPRECATED_MAKE_FAST_ALLOCATED(TestIsolationBaseline);

public:
struct Entry {
JSC::EncodedJSValue value;
uint8_t attributes;
};
WTF::UncheckedKeyHashMap<WTF::RefPtr<WTF::UniquedStringImpl>, Entry> ownProperties;

Check failure on line 706 in src/jsc/bindings/ZigGlobalObject.cpp

View check run for this annotation

Claude / Claude Code Review

Baseline own-property values stored as unrooted EncodedJSValue in malloc memory

`TestIsolationBaseline::Entry::value` stores baseline slot values as raw `JSC::EncodedJSValue` in a `WTF::UncheckedKeyHashMap` inside a fast-allocated (malloc'd) struct held from capture through the entire test file's execution — a direct violation of REVIEW.md's "never raw JSValues in malloc'd memory or std containers". If a test overwrites a baseline global (e.g. `globalThis.setTimeout`), the original cell can be collected and its IsoSubspace slot reused, causing the encoded-pointer equality c
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
unsigned lexicalSymbolTableSize { 0 };
unsigned varSymbolTableSize { 0 };
Zig::GlobalObject* capturedGlobal { nullptr };
unsigned reuseCount { 0 };
unsigned swapCount { 0 };
};

} // namespace Bun

// Records the post-preload own-property set of `globalObject` so the next
// file's swap can compare against it. Called from Rust between preload and the
// file's own module load, and only on the first file after a fresh global.
Comment thread
robobun marked this conversation as resolved.
extern "C" void Zig__GlobalObject__captureTestIsolationBaseline(Zig::GlobalObject* globalObject)
{
JSC::VM& vm = globalObject->vm();
JSC::JSLockHolder locker(vm);
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
auto* clientData = WebCore::clientData(vm);
if (!clientData->testIsolationBaseline)
clientData->testIsolationBaseline.reset(new Bun::TestIsolationBaseline);
auto& baseline = *clientData->testIsolationBaseline;
baseline.ownProperties.clear();
baseline.capturedGlobal = globalObject;
baseline.lexicalSymbolTableSize = globalObject->globalLexicalEnvironment()->symbolTable()->size();

// Reify static hash-table entries (setTimeout, fetch, process, …) into
// own-property storage so the baseline holds their canonical values and the
// scrub never mistakes a lazy reification for a user leak.
Comment thread
robobun marked this conversation as resolved.
if (!globalObject->staticPropertiesReified())
globalObject->reifyAllStaticProperties(globalObject);
if (scope.exception()) [[unlikely]] {
scope.clearException();
baseline.capturedGlobal = nullptr;
return;
}

baseline.varSymbolTableSize = globalObject->symbolTable()->size();

globalObject->structure()->forEachProperty(vm, [&](const auto& entry) -> bool {
baseline.ownProperties.add(entry.key(),
Bun::TestIsolationBaseline::Entry {
JSC::JSValue::encode(globalObject->getDirect(entry.offset())),
entry.attributes() });
return true;
});
}

// Returns true if `globalObject` was scrubbed in place and can be reused for
// the next file; false if a full swap (createForTestIsolation) is required.
// Callers have already run the runtime-side cleanup (sockets, timers, handles).
Comment thread
robobun marked this conversation as resolved.
extern "C" bool Zig__GlobalObject__tryResetForTestIsolation(Zig::GlobalObject* globalObject)
{
JSC::VM& vm = globalObject->vm();
JSC::JSLockHolder locker(vm);
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
auto* clientData = WebCore::clientData(vm);
Comment thread
claude[bot] marked this conversation as resolved.
auto* baseline = clientData->testIsolationBaseline.get();
if (!baseline || baseline->capturedGlobal != globalObject)
return false;

auto swap = [&] { baseline->swapCount++; return false; };
Comment thread
robobun marked this conversation as resolved.
Outdated

// These watchpoints are one-shot; a fresh global re-arms them.
if (globalObject->isHavingABadTime()
|| !globalObject->objectPrototypeChainIsSane()
|| !globalObject->arrayPrototypeChainIsSane()
|| !globalObject->stringPrototypeChainIsSane()
|| !globalObject->arrayIteratorProtocolWatchpointSet().isStillValid()
|| !globalObject->mapIteratorProtocolWatchpointSet().isStillValid()
|| !globalObject->setIteratorProtocolWatchpointSet().isStillValid())
return swap();

// Top-level `let`/`const`/`class` in a sloppy-mode script land here and
// can't be deleted.
Comment thread
robobun marked this conversation as resolved.
if (globalObject->globalLexicalEnvironment()->symbolTable()->size() != baseline->lexicalSymbolTableSize)
return swap();
if (globalObject->symbolTable()->size() != baseline->varSymbolTableSize)
return swap();

if (globalObject->hasOverriddenModuleWrapper
|| globalObject->hasOverriddenModuleResolveFilenameFunction
|| globalObject->hasOverriddenModuleRunMain
|| globalObject->m_errorConstructorPrepareStackTraceValue.get())
return swap();
Comment thread
robobun marked this conversation as resolved.
Outdated

// A changed baseline slot value/attributes = user overwrote a built-in;
// an extra own property = leak to scrub.
Comment thread
robobun marked this conversation as resolved.
WTF::Vector<JSC::Identifier, 16> toDelete;
unsigned seen = 0;
bool dirty = false;
globalObject->structure()->forEachProperty(vm, [&](const auto& entry) -> bool {
auto it = baseline->ownProperties.find(entry.key());
if (it == baseline->ownProperties.end()) {
toDelete.append(JSC::Identifier::fromUid(vm, entry.key()));
return true;
}
seen++;
if (entry.attributes() != it->value.attributes
|| JSC::JSValue::encode(globalObject->getDirect(entry.offset())) != it->value.value) {
dirty = true;
return false;
}
return true;
});
if (dirty || seen != baseline->ownProperties.size())
return swap();

for (auto& id : toDelete) {
JSC::DeletePropertySlot slot;
bool deleted = JSC::JSCell::deleteProperty(globalObject, globalObject, id, slot);
if (scope.exception() || !deleted) [[unlikely]] {
scope.clearException();
return swap();
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Drop project modules (absolute path outside node_modules) so their state
// resets; keep node_modules/builtin records so their CodeBlocks survive.
Comment thread
robobun marked this conversation as resolved.
Outdated
auto isProjectPath = [](WTF::StringView key) {
if (key.isEmpty())
return false;
#if OS(WINDOWS)
if (key.contains("\\node_modules\\"_s) || key.contains("/node_modules/"_s))
return false;
return key.length() >= 2 && (key[1] == ':' || (key[0] == '\\' && key[1] == '\\'));
#else
return key[0] == '/' && !key.contains("/node_modules/"_s);
#endif
};
{
auto* moduleLoader = globalObject->moduleLoader();
WTF::Vector<JSC::Identifier, 32> evict;
for (auto& [key, entry] : moduleLoader->moduleMap()) {
UNUSED_VARIABLE(entry);
if (isProjectPath(WTF::StringView(key.first)))
evict.append(JSC::Identifier::fromUid(vm, key.first));
}
WTF::Locker locker { moduleLoader->cellLock() };
for (auto& id : evict)
moduleLoader->removeEntry(id);

Check failure on line 846 in src/jsc/bindings/ZigGlobalObject.cpp

View check run for this annotation

Claude / Claude Code Review

node_modules preloads never re-evaluate on reuse, so their cleared hooks/listeners never re-register

`isProjectPath` skips `/node_modules/` module records, but the per-file reset that runs unconditionally around it (`reset_hook_scope_for_test_isolation()` + `globalEventScope->removeAllEventListeners()`) drops preload-registered hooks/listeners. On the next file, `load_preloads` → `JSModuleLoader::import_ptr(resolved_path)` hits the surviving registry entry for a node_modules preload (e.g. `--preload @testing-library/jest-dom`, or transitively via `./setup.ts` → `import 'pkg'`) and returns the c
Comment thread
robobun marked this conversation as resolved.
Outdated
Comment thread
robobun marked this conversation as resolved.
}
{
auto* requireMap = globalObject->requireMap();
WTF::Vector<JSC::JSValue, 32> evict;
auto* iter = JSC::JSMapIterator::create(vm, globalObject->mapIteratorStructure(), requireMap, JSC::IterationKind::Keys);
scope.assertNoException();
JSC::JSValue value;
while (iter->next(globalObject, value)) {
if (auto* str = value.toStringOrNull(globalObject); str && isProjectPath(str->view(globalObject)))
evict.append(value);
scope.assertNoException();
}
for (auto& key : evict) {
requireMap->remove(globalObject, key);
scope.assertNoException();
}
}

globalObject->m_nextTickQueue.clear();

Check failure on line 865 in src/jsc/bindings/ZigGlobalObject.cpp

View check run for this annotation

Claude / Claude Code Review

m_nextTickQueue.clear() orphans reified process.nextTick closure — callbacks silently dropped after reuse

`m_nextTickQueue.clear()` orphans the already-reified `process.nextTick` closure: the closure (and `process->m_nextTickFunction`) still points at the old `JSNextTickQueue`, so after a successful reuse the next file's `process.nextTick(cb)` pushes into a queue that no drain site can reach — every drain reads `globalObject->m_nextTickQueue.get()`, sees null, and skips. Either drop this line (the pre-reset microtask drain has already emptied the queue) or also clear `processObject()->m_nextTickFunc
Comment thread
robobun marked this conversation as resolved.
Outdated
globalObject->mockModule.activeSpies.clear();
globalObject->mockModule.activeMocks.clear();

Check failure on line 867 in src/jsc/bindings/ZigGlobalObject.cpp

View check run for this annotation

Claude / Claude Code Review

activeSpies.clear() orphans installed spies without restoring — should use JSMock__resetSpies

`activeSpies.clear()` drops the spy-tracking set without calling `clearSpy()` on each entry, so a `jest.spyOn(console, 'log')` from file A stays installed on the surviving `console` object AND file B's `jest.restoreAllMocks()` becomes a no-op — the original is unrecoverable. This is strictly worse than `--no-isolate` (where `restoreAllMocks()` would still work) and worse than the documented "not detected" limitation, which only promised the mutation leaks, not that recovery is destroyed. Replace
Comment thread
robobun marked this conversation as resolved.
Outdated
globalObject->globalEventScope->removeAllEventListeners();
// The Rust side already restored the OS cwd; drop the JS-side cache so the
// next `process.cwd()` re-reads it.
Comment thread
robobun marked this conversation as resolved.
Outdated
if (globalObject->hasProcessObject())
globalObject->processObject()->clearCachedCwd();
Comment thread
robobun marked this conversation as resolved.
Outdated

baseline->reuseCount++;
return true;
Comment thread
robobun marked this conversation as resolved.
Outdated
}

extern "C" void Zig__GlobalObject__testIsolationResetStats(Zig::GlobalObject* globalObject, uint32_t* reuse, uint32_t* swap)
{
auto* baseline = WebCore::clientData(globalObject->vm())->testIsolationBaseline.get();
*reuse = baseline ? baseline->reuseCount : 0;
*swap = baseline ? baseline->swapCount : 0;
}

void WebCore::JSVMClientData::TestIsolationBaselineDeleter::operator()(Bun::TestIsolationBaseline* p) const
{
delete p;
}

JSC_DEFINE_HOST_FUNCTION(functionFulfillModuleSync,
(JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame))
{
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/cli/test/parallel/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,9 @@ pub(crate) fn run_as_worker(
let vm_ref = unsafe { &mut *vm };
vm_ref.test_isolation_enabled = ctx.test_options.isolate;
vm_ref.auto_killer.enabled = ctx.test_options.isolate;
vm_ref.test_isolation_state.global_reuse = ctx.test_options.isolate
&& bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_EXPERIMENTAL_ISOLATION_GLOBAL_REUSE::get()
.unwrap_or(false);

// `vm.arena` is currently a write-only backref: the `MimallocArena.gc()`
// reader was dropped from the GC path (see web_worker.rs, which wires its
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2339,6 +2339,9 @@ impl TestCommand {
if ctx.test_options.isolate {
vm.test_isolation_enabled = true;
vm.auto_killer.enabled = true;
vm.test_isolation_state.global_reuse =
bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_EXPERIMENTAL_ISOLATION_GLOBAL_REUSE::get()
.unwrap_or(false);
}

if ctx.test_options.coverage.enabled {
Expand Down
Loading
Loading