diff --git a/src/jsc/bindings/webcore/AbortSignal.h b/src/jsc/bindings/webcore/AbortSignal.h index 1442edfe8806..7eae1b2f3ba6 100644 --- a/src/jsc/bindings/webcore/AbortSignal.h +++ b/src/jsc/bindings/webcore/AbortSignal.h @@ -123,6 +123,11 @@ class AbortSignal final : public RefCounted, public EventTargetWith const AbortSignalSet& sourceSignals() const { return m_sourceSignals; } AbortSignalSet& sourceSignals() { return m_sourceSignals; } + // Read-only emptiness probe for GC marker threads (JSAbortSignalOwner::isReachableFromOpaqueRoots). + // WeakListHashSet::isEmptyIgnoringNullReferences() prunes dead entries, destroying WeakPtrs whose + // single-threaded impls (and the nodes holding them) may only be released on the owning thread. + bool hasAliveSourceSignals() const { return m_sourceSignals.begin() != m_sourceSignals.end(); } + // https://github.com/oven-sh/bun/issues/4517 void incrementPendingActivityCount() { ++pendingActivityCount; } void decrementPendingActivityCount() { --pendingActivityCount; } diff --git a/src/jsc/bindings/webcore/JSAbortSignalCustom.cpp b/src/jsc/bindings/webcore/JSAbortSignalCustom.cpp index 627d48b49cff..85165e698197 100644 --- a/src/jsc/bindings/webcore/JSAbortSignalCustom.cpp +++ b/src/jsc/bindings/webcore/JSAbortSignalCustom.cpp @@ -53,7 +53,9 @@ bool JSAbortSignalOwner::isReachableFromOpaqueRoots(JSC::Handle ha return true; } if (abortSignal.isDependent()) { - if (!abortSignal.sourceSignals().isEmptyIgnoringNullReferences()) { + // This runs on GC marker threads, so it must not mutate the signal: + // sourceSignals().isEmptyIgnoringNullReferences() prunes dead entries. + if (abortSignal.hasAliveSourceSignals()) { if (reason) [[unlikely]] *reason = "Has Source Signals And Abort Event Listener"_s; return true; diff --git a/test/js/web/abort/abort-controller-gc-reason.test.ts b/test/js/web/abort/abort-controller-gc-reason.test.ts index 5f294769363c..1237e09659dc 100644 --- a/test/js/web/abort/abort-controller-gc-reason.test.ts +++ b/test/js/web/abort/abort-controller-gc-reason.test.ts @@ -99,6 +99,59 @@ describe("AbortController GC", () => { expect(exitCode).toBe(0); }); + // JSAbortSignalOwner::isReachableFromOpaqueRoots runs on JSC's parallel marker threads and + // must not prune the dependent signal's source set there: doing so destroys WeakPtrImpls + // owned by the JS thread once every source controller has been collected. + test("AbortSignal.any() dependent signals survive parallel GC after their sources are collected", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const noop = () => {}; + function makeBatch(n) { + const out = []; + for (let i = 0; i < n; i++) { + // The controllers (and their signals) are dropped, so every source of the + // dependent signal is collected, leaving only dead weak references behind. + const a = new AbortController(); + const b = new AbortController(); + const c = new AbortController(); + const dep = AbortSignal.any([a.signal, b.signal, c.signal]); + dep.addEventListener("abort", noop); + out.push(dep); + } + return out; + } + const keep = []; + for (let round = 0; round < 24; round++) { + keep.push(makeBatch(100)); + if (keep.length > 6) keep.shift(); + Bun.gc(true); + } + console.log("PASS"); + `, + ], + env: { + ...bunEnv, + // `bun -e` defaults to a single GC marker; the weak-handle visit has to happen on + // the parallel marker threads to reach the cross-thread mutation. + BUN_JSC_numberOfGCMarkers: "8", + }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // stderr is only reported for diagnostics; debug/ASAN builds may emit benign warnings. + expect({ stdout: stdout.trim(), exitCode, stderr }).toEqual({ + stdout: "PASS", + exitCode: 0, + stderr: expect.any(String), + }); + }); + test("signal.reason survives GC with many controllers", async () => { await using proc = Bun.spawn({ cmd: [