Repository: denoland/deno
Pull request: fix(ext/web): prevent GC of AbortSignal.any()

Closes https://github.com/denoland/deno/issues/32095

Introduced with V8 bump https://github.com/denoland/deno/pull/31873

The new exposed `AbortSignal.any()`, stores dependent signals via `WeakRef` in the source signal's `dependentSignals` set. When the dependent signal is created as a temporary (e.g. `AbortSignal.any([...]).addEventListener("abort", cb)`), there are no strong references to it. V8's GC can collect it, destroying the abort listener and leaving promises unresolvable.

The fix stores a strong reference to dependent signals on the source signal (via an `activeDependents` set) whenever `addEventListener` refs a source timer. The strong reference is cleaned up in `removeEventListener` and `runAbortSteps`.

Changed files:
filename: ext/web/03_abort_signal.js
patch: @@ -79,6 +79,7 @@ const sourceSignals = Symbol("[[sourceSignals]]");
 const dependentSignals = Symbol("[[dependentSignals]]");
 const signal = Symbol("[[signal]]");
 const timerId = Symbol("[[timerId]]");
+const activeDependents = Symbol("[[activeDependents]]");
 
 const illegalConstructorKey = Symbol("illegalConstructorKey");
 
@@ -188,6 +189,17 @@ class AbortSignal extends EventTarget {
       setIsTrusted(event, true);
       super.dispatchEvent(event);
     }
+
+    // release strong references from source signals now that abort has been delivered
+    if (this[sourceSignals] !== null) {
+      const sourceSignalArray = this[sourceSignals].toArray();
+      for (let i = 0; i < sourceSignalArray.length; ++i) {
+        const sourceSignal = sourceSignalArray[i];
+        if (sourceSignal[activeDependents]) {
+          SetPrototypeDelete(sourceSignal[activeDependents], this);
+        }
+      }
+    }
   }
 
   [remove](algorithm) {
@@ -234,6 +246,9 @@ class AbortSignal extends EventTarget {
           const sourceSignal = sourceSignalArray[i];
           if (sourceSignal[timerId] !== null) {
             refTimer(sourceSignal[timerId]);
+            // prevent GC of this dependent signal while the timer is keeping the event loop alive
+            sourceSignal[activeDependents] ??= new SafeSet();
+            SetPrototypeAdd(sourceSignal[activeDependents], this);
           }
         }
       }
@@ -261,6 +276,10 @@ class AbortSignal extends EventTarget {
             ) {
               unrefTimer(sourceSignal[timerId]);
             }
+            // release the strong reference since no more listeners need it
+            if (sourceSignal[activeDependents]) {
+              SetPrototypeDelete(sourceSignal[activeDependents], this);
+            }
           }
         }
       }

filename: tests/specs/run/abort_signal_any_gc/__test__.jsonc
patch: @@ -0,0 +1,8 @@
+{
+  "tests": {
+    "dependent_signal_survives_gc": {
+      "args": "run --v8-flags=--expose-gc main.ts",
+      "output": "ok\n"
+    }
+  }
+}

filename: tests/specs/run/abort_signal_any_gc/main.ts
patch: @@ -0,0 +1,33 @@
+// Regression test: AbortSignal.any() dependent signals must not be GC'd while
+// they have abort listeners and a source timer keeps the event loop alive.
+//
+// The dependent signal from AbortSignal.any() is only held via WeakRef in the
+// source signal's dependentSignals set. Without a strong reference, GC collects
+// it, the abort listener is lost, and the promise never resolves.
+
+declare const gc: (opts?: object) => void;
+
+const ac = new AbortController();
+const { promise, resolve } = Promise.withResolvers<void>();
+
+// Wrap signal creation in a function so the dependent signal is not held
+// on the current stack frame when GC runs.
+function setup() {
+  AbortSignal.any([
+    AbortSignal.timeout(500),
+    ac.signal,
+  ]).addEventListener("abort", () => resolve());
+}
+setup();
+
+// GC must run from a fresh macrotask so the setup() stack frame is fully
+// gone and V8 can see the dependent signal as unreachable (only weak refs).
+setTimeout(() => {
+  gc({ type: "major", execution: "sync" });
+}, 10);
+
+// Without the fix the dependent signal is collected, the abort listener
+// disappears, and this await hangs until the event loop exits with:
+// "Promise resolution is still pending but the event loop has already resolved"
+await promise;
+console.log("ok");

filename: tests/wpt/runner/expectation.json
patch: @@ -2368,7 +2368,7 @@
       "event.any.html": true,
       "event.any.worker.html": true,
       "abort-signal-any.any.html": true,
-      "abort-signal-any.any.worker.html": false,
+      "abort-signal-any.any.worker.html": true,
       "abort-signal-timeout.html": false,
       "reason-constructor.html": false
     },