Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
27 changes: 23 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,24 @@ 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. Preload
// re-evaluates on the reused global, so re-capture the baseline after.
Comment thread
robobun marked this conversation as resolved.
if self.test_isolation_state.global_reuse
&& JSGlobalObject::try_reset_for_test_isolation(old_global_ref)
{
self.test_isolation_state.baseline_captured = false;
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);

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

extern "C" void JSMock__resetSpies(Zig::GlobalObject*);

namespace Bun {

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

public:
struct Entry {
JSC::Strong<JSC::Unknown> value;
uint8_t attributes;
};
WTF::UncheckedKeyHashMap<WTF::RefPtr<WTF::UniquedStringImpl>, Entry> ownProperties;
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
// Module keys present at capture time (i.e. loaded by --preload). Evicted
// on reset so preload re-evaluates and re-registers its hooks; the
// node_modules-keeping only applies to what the test file loaded on top.
Comment thread
robobun marked this conversation as resolved.
WTF::UncheckedKeyHashSet<WTF::RefPtr<WTF::UniquedStringImpl>> preloadModuleKeys;
WTF::UncheckedKeyHashSet<WTF::String> preloadRequireKeys;
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::Strong<JSC::Unknown>(vm, globalObject->getDirect(entry.offset())),
entry.attributes() });
return true;
});

baseline.preloadModuleKeys.clear();
for (auto& [key, entry] : globalObject->moduleLoader()->moduleMap()) {
UNUSED_VARIABLE(entry);
baseline.preloadModuleKeys.add(key.first);
}
baseline.preloadRequireKeys.clear();
{
auto* iter = JSC::JSMapIterator::create(vm, globalObject->mapIteratorStructure(), globalObject->requireMap(), JSC::IterationKind::Keys);
scope.assertNoException();
JSC::JSValue value;
while (iter->next(globalObject, value)) {
if (auto* str = value.toStringOrNull(globalObject))
baseline.preloadRequireKeys.add(str->value(globalObject));
scope.assertNoException();
}
}

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

View check run for this annotation

Claude / Claude Code Review

Re-capture after reuse folds surviving node_modules into preloadModuleKeys

Re-capturing the baseline on a reused global (c36cf411's `baseline_captured = false`) folds surviving node_modules into `preloadModuleKeys`: at re-capture time the moduleMap still contains every node_modules record the previous reset kept, and this loop snapshots the entire map, so on the *next* reset `shouldEvict` returns true for them and they're dropped. A node_modules dep loaded by file N survives into N+1 (one hit) then is evicted before N+2, alternating hit/miss thereafter — directly contr
Comment thread
robobun marked this conversation as resolved.
}

// 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

// Restore spies before the own-property compare so a spyOn(globalThis, ...)
// is reverted (baseline then matches) and the scrub can't be undone by
// clearSpy putDirect'ing a deleted key back.
Comment thread
robobun marked this conversation as resolved.
JSMock__resetSpies(globalObject);
if (scope.exception()) [[unlikely]] {
scope.clearException();
return swap();
}

// 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
|| globalObject->getDirect(entry.offset()) != it->value.value.get()) {
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 and everything preload loaded (so the preload chain
// re-evaluates and re-registers its hooks); keep node_modules the test file
// loaded on top so their CodeBlocks survive.
Comment thread
robobun marked this conversation as resolved.
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 shouldEvict = [&](WTF::UniquedStringImpl* key) {
return baseline->preloadModuleKeys.contains(key) || isProjectPath(WTF::StringView(key));
};
{
auto* moduleLoader = globalObject->moduleLoader();
WTF::Vector<JSC::Identifier, 32> evict;
for (auto& [key, entry] : moduleLoader->moduleMap()) {
UNUSED_VARIABLE(entry);
if (shouldEvict(key.first))
evict.append(JSC::Identifier::fromUid(vm, key.first));
}
WTF::Locker locker { moduleLoader->cellLock() };
for (auto& id : evict)
moduleLoader->removeEntry(id);
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)) {
auto view = str->view(globalObject);
if (isProjectPath(view) || baseline->preloadRequireKeys.contains<WTF::StringViewHashTranslator>(view))
evict.append(value);
}
scope.assertNoException();
}
for (auto& key : evict) {
requireMap->remove(globalObject, key);
scope.assertNoException();
}
}

globalObject->mockModule.activeMocks.clear();
globalObject->globalEventScope->removeAllEventListeners();
globalObject->overridenDateNow = JSC::PNaN;
if (globalObject->hasProcessObject()) {
auto* process = globalObject->processObject();
process->wrapped().removeAllListeners();
process->clearCachedCwd();
}

Check warning on line 912 in src/jsc/bindings/ZigGlobalObject.cpp

View check run for this annotation

Claude / Claude Code Review

process.setUncaughtExceptionCaptureCallback state survives reuse

The `hasProcessObject()` reset block clears listeners and `m_cachedCwd` but leaves `m_uncaughtExceptionCaptureCallback` / `m_reportOnUncaughtException` untouched, so a file (or preload) that calls `process.setUncaughtExceptionCaptureCallback(fn)` causes the next reused file's call to throw `ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET` (BunProcess.cpp:907). Same native-per-object-field class as `overridenDateNow` and `m_cachedCwd` already fixed in this block; add `process->setUncaughtExceptionCapt
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.

baseline->reuseCount++;
return true;
}

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
Loading