Skip to content
Merged
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
19 changes: 14 additions & 5 deletions src/jsc/bindings/JSCTaskScheduler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#include <JavaScriptCore/VM.h>
#include <JavaScriptCore/TopExceptionScope.h>
#include <JavaScriptCore/JSGlobalObject.h>
#include <JavaScriptCore/GlobalObjectMethodTable.h>
#include "JSCTaskScheduler.h"
#include "BunClientData.h"
#include "ZigGlobalObject.h"
Expand Down Expand Up @@ -121,11 +122,19 @@ static void runPendingWork(const ::BunVmHandleRef* vmHandle, Bun::JSCTaskSchedul
if (wasPending && !job->ticket->isCancelled() && Bun__VmHandle__scriptAllowed(vmHandle)) {
auto& vm = job->vm();
auto* globalObject = job->ticket->target()->globalObject();
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
job->task(job->ticket.get());
if (auto* exception = scope.exception(); exception && !vm.hasPendingTerminationException()) {
scope.clearException();
Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception);
// The realm's own status, as DeferredWorkTimer::doWork asks it before it runs a
// task. A realm that `bun test --isolate` retired reports Stopped, so the
// finished file's leftover work is dropped instead of running under the next
// file. doWork re-queues a Suspended realm's task; no Bun realm reports that.
auto status = globalObject->globalObjectMethodTable()->scriptExecutionStatus(globalObject, job->ticket->scriptExecutionOwner());
ASSERT(status != ScriptExecutionStatus::Suspended);
if (status == ScriptExecutionStatus::Running) {
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
job->task(job->ticket.get());
if (auto* exception = scope.exception(); exception && !vm.hasPendingTerminationException()) {
scope.clearException();
Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception);
}
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,9 @@ JSGlobalObject* GlobalObject::deriveShadowRealmGlobalObject(JSGlobalObject* glob
extern "C" int Bun__VM__scriptExecutionStatus(void*);
JSC::ScriptExecutionStatus Zig::GlobalObject::scriptExecutionStatus(JSC::JSGlobalObject* globalObject, JSC::JSObject*)
{
// A finished file's realm is stopped while the VM runs on, as a detached document's is.
if (Bun::isRetiredTestIsolationRealm(globalObject))
return JSC::ScriptExecutionStatus::Stopped;
switch (Bun__VM__scriptExecutionStatus(uncheckedDowncast<Zig::GlobalObject>(globalObject)->bunVM())) {
case 0:
return JSC::ScriptExecutionStatus::Running;
Expand Down Expand Up @@ -4381,7 +4384,9 @@ extern "C" void Zig__GlobalObject__stopActiveDOMObjectsForTestIsolation(Zig::Glo
}

// `bun test --isolate`: JSC drops microtasks queued against the finished file's realm from here on
// (as WebCore does for a stopped document), so work it left in flight cannot resume its script.
// (as WebCore does for a stopped document), and the realm reports ScriptExecutionStatus::Stopped so
// its deferred work (FinalizationRegistry cleanup, wasm completions) is dropped too. Work the file
// left in flight cannot resume its script.
extern "C" void Zig__GlobalObject__retireForTestIsolation(Zig::GlobalObject* globalObject)
{
globalObject->setMicrotaskRunnability(JSC::QueuedTaskResult::Discard);
Expand All @@ -4391,7 +4396,7 @@ extern "C" void Zig__GlobalObject__retireForTestIsolation(Zig::GlobalObject* glo
extern "C" bool Bun__JSValue__isFromRetiredTestIsolationRealm(JSC::EncodedJSValue encodedValue)
{
JSC::JSObject* object = JSC::JSValue::decode(encodedValue).getObject();
return object && object->globalObject()->microtaskRunnability() == JSC::QueuedTaskResult::Discard;
return object && Bun::isRetiredTestIsolationRealm(object->globalObject());
}

extern "C" void Zig__GlobalObject__destructOnExit(Zig::GlobalObject* globalObject)
Expand Down
7 changes: 7 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.h
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,13 @@ ALWAYS_INLINE void* vm(JSC::JSGlobalObject* lexicalGlobalObject)
return WebCore::clientData(lexicalGlobalObject->vm())->bunVM;
}

// A realm that `bun test --isolate` retired because its file finished
// (Zig__GlobalObject__retireForTestIsolation). Nothing enters its script again.
ALWAYS_INLINE bool isRetiredTestIsolationRealm(const JSC::JSGlobalObject* globalObject)
{
return globalObject->microtaskRunnability() == JSC::QueuedTaskResult::Discard;
}

}

#ifndef RENAMED_JSDOM_GLOBAL_OBJECT
Expand Down
62 changes: 62 additions & 0 deletions test/cli/test/isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,68 @@ describe.concurrent("--isolate: a finished file's late completions do not run in
if (exitCode !== 0) expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

// A FinalizationRegistry cleanup that a finished file created reaches none of
// the checks above: JSC calls it directly from a DeferredWorkTimer job, not
// through a microtask, a Job or run_callback. The cleanup ran in the retired
// realm while a later file executed, and an error it threw was charged to
// that later file (which then lost its remaining tests to "Cannot call test()
// after the test run has completed"). The retired realm now reports its
// script execution as stopped, so Bun drops the job, as JSC's own
// DeferredWorkTimer::doWork does for a stopped realm.
test("a finished file's FinalizationRegistry cleanup does not run in the next file", async () => {
using dir = tempDir("isolate-finalization-registry", {
"a-registry.test.ts": `
import { test, expect } from "bun:test";
import { existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";

const dir = import.meta.dir;
// The cleanup only acts once b-registry.test.ts says it is running, and it
// registers fresh garbage each time, so this registry has dead entries at
// every collection for as long as its realm is alive.
const registry = new FinalizationRegistry(() => {
if (existsSync(join(dir, "b-running"))) writeFileSync(join(dir, "a-acted"), "");
registry.register({}, 0);
});

test("registers objects that die at once", () => {
for (let i = 0; i < 200; i++) registry.register({ i }, i);
expect(existsSync(join(dir, "b-running"))).toBe(false);
});
`,
"b-registry.test.ts": `
import { test, expect } from "bun:test";
import { existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";

const dir = import.meta.dir;
writeFileSync(join(dir, "b-running"), "");

test("A's FinalizationRegistry cleanup does not run here", async () => {
// Collect, then round-trip the thread pool, so a cleanup A left queued gets
// every chance to land here.
for (let i = 0; i < 20 && !existsSync(join(dir, "a-acted")); i++) {
Bun.gc(true);
await Bun.password.hash("pw", { algorithm: "bcrypt", cost: 4 });
}
expect(existsSync(join(dir, "a-acted"))).toBe(false);
});
`,
});
const { stderr, exitCode } = await runTests(
String(dir),
["--isolate"],
["./a-registry.test.ts", "./b-registry.test.ts"],
// A collects between its last drain and the swap, which is when the cleanup it
// leaves behind is queued. Natural GC timing does that only sometimes.
{ ...bunEnv, BUN_JSC_collectContinuously: "1" },
);
expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass");
expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail");
if (exitCode !== 0) expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
});

// Each of these leaked handles used to pin its test file's ENTIRE global
Expand Down
Loading