Skip to content

Commit 4b7abe8

Browse files
aryansavesaduh95
authored andcommitted
events: fix weak listener retention overwrite
The weakListeners() retention map in EventTarget stored a single listener per retainer key. Multiple addEventListener() calls sharing the same retainer (e.g. two listeners on one target using the same AbortSignal) silently evicted each other's strong reference, allowing the evicted listener to be garbage collected before its signal aborted, so only the most recently registered listener was ever actually removed on abort. Changed the retention map to store a Set of listeners per retainer key instead of a single listener, and added matching cleanup in remove() so entries are released once their retainer's set is empty. This also fixes the same class of bug in events.aborted() and the streams kWeakHandler usage, since both go through the same EventTarget.prototype.addEventListener() code path. Fixes: #63954 Signed-off-by: aryan7905 <aryansrivastava354@gmail.com> PR-URL: #64024 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Aviv Keller <me@aviv.sh> Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
1 parent 922d4c4 commit 4b7abe8

3 files changed

Lines changed: 58 additions & 4 deletions

File tree

lib/internal/event_target.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
ReflectApply,
1515
SafeFinalizationRegistry,
1616
SafeMap,
17+
SafeSet,
1718
SafeWeakMap,
1819
SafeWeakRef,
1920
SafeWeakSet,
@@ -491,8 +492,17 @@ class Listener {
491492
listener: this,
492493
eventType,
493494
}, this);
494-
// Make the retainer retain the listener in a WeakMap
495-
weakListeners().map.set(weak, listener);
495+
// Store only a WeakRef to the retainer here. Listener instances are
496+
// Strongly reachable from the EventTarget's listener list for as long
497+
// as they're registered, so a plain strong reference would keep the
498+
// retainer (and listener) alive forever, defeating weak retention
499+
this.weakKeyRef = new SafeWeakRef(weak);
500+
let retained = weakListeners().map.get(weak);
501+
if (retained === undefined) {
502+
retained = new SafeSet();
503+
weakListeners().map.set(weak, retained);
504+
}
505+
retained.add(listener);
496506
this.listener = this.callback;
497507
} else if (typeof listener === 'function') {
498508
this.callback = listener;
@@ -545,8 +555,19 @@ class Listener {
545555
if (this.next !== undefined)
546556
this.next.previous = this.previous;
547557
this.removed = true;
548-
if (this.weak)
558+
if (this.weak) {
549559
weakListeners().registry.unregister(this);
560+
const weakKey = this.weakKeyRef?.deref();
561+
const listener = this.callback?.deref();
562+
if (weakKey !== undefined && listener !== undefined) {
563+
const retained = weakListeners().map.get(weakKey);
564+
if (retained !== undefined) {
565+
retained.delete(listener);
566+
if (retained.size === 0)
567+
weakListeners().map.delete(weakKey);
568+
}
569+
}
570+
}
550571
}
551572
}
552573

test/parallel/test-abortcontroller-internal.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,22 @@ test('A weak event listener should not prevent gc', async () => {
3030
globalThis.gc();
3131
assert.strictEqual(ref.deref(), undefined);
3232
});
33+
34+
test('two weak listeners with the same retainer should both run on abort', async () => {
35+
// Regression test for https://github.com/nodejs/node/issues/63954
36+
const ac = new AbortController();
37+
let aRan = false;
38+
let bRan = false;
39+
40+
ac.signal.addEventListener('a', () => { aRan = true; }, { [kWeakHandler]: ac });
41+
ac.signal.addEventListener('b', () => { bRan = true; }, { [kWeakHandler]: ac });
42+
43+
await sleep(10);
44+
globalThis.gc();
45+
46+
ac.signal.dispatchEvent(new Event('a'));
47+
ac.signal.dispatchEvent(new Event('b'));
48+
49+
assert.strictEqual(aRan, true);
50+
assert.strictEqual(bRan, true);
51+
});

test/parallel/test-eventtarget.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,21 @@ let asyncTest = Promise.resolve();
699699
et.dispatchEvent(new Event('foo'));
700700
});
701701
}
702-
702+
{
703+
// Two listeners sharing the same retainer key must NOT evict each
704+
// other from the weak retention map — both must survive a GC cycle
705+
// and both must be removable independently.
706+
// Regression test for https://github.com/nodejs/node/issues/63954
707+
const et = new EventTarget();
708+
const aCalled = common.mustNotCall();
709+
const bCalled = common.mustCall();
710+
et.addEventListener('a', aCalled, { [kWeakHandler]: et });
711+
et.addEventListener('b', bCalled, { [kWeakHandler]: et });
712+
globalThis.gc();
713+
et.removeEventListener('a', aCalled);
714+
et.dispatchEvent(new Event('a'));
715+
et.dispatchEvent(new Event('b'));
716+
}
703717
{
704718
const et = new EventTarget();
705719

0 commit comments

Comments
 (0)