From 16b383ab6fcb3bf9fbed93d62fa266028e4f6fae Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 09:16:52 -0800 Subject: [PATCH] fix(area): detach a lookup's abort listener once it settles Each resolve() that raced the caller's signal added an abort listener and never removed it, so a long-lived signal collected one listener per completed lookup. The listener is now removed when the call settles and is not added at all to a signal that is already aborted. --- src/data/areaResolver.js | 21 +++++++++++++-------- src/data/areaResolver.test.mjs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/data/areaResolver.js b/src/data/areaResolver.js index 1134e37ca..f222d3c8c 100644 --- a/src/data/areaResolver.js +++ b/src/data/areaResolver.js @@ -626,24 +626,29 @@ export function createAreaResolver({ ); }), ); + // Detached once this call settles: a caller's long-lived signal must not + // collect one listener per completed lookup. + let cancelled = null; if (signal) racers.push( new Promise((resolveAbort) => { - const cancelled = () => - resolveAbort({ - ok: false, - code: 'CANCELLED', - cancelled: true, - error: 'Superseded.', - }); - if (signal.aborted) cancelled(); + const superseded = { + ok: false, + code: 'CANCELLED', + cancelled: true, + error: 'Superseded.', + }; + if (signal.aborted) return resolveAbort(superseded); + cancelled = () => resolveAbort(superseded); signal.addEventListener?.('abort', cancelled, { once: true }); + return undefined; }), ); try { return finish(await Promise.race(racers)); } finally { clearTimeout(timer); + if (cancelled) signal.removeEventListener?.('abort', cancelled); } } diff --git a/src/data/areaResolver.test.mjs b/src/data/areaResolver.test.mjs index 4c411b5ac..f38f1448e 100644 --- a/src/data/areaResolver.test.mjs +++ b/src/data/areaResolver.test.mjs @@ -556,6 +556,36 @@ test('distinct area lookups are hard-bounded while same-key callers still coales assert.equal(b.ok, true); }); +test('settled lookups leave no listener on the caller\'s signal', async () => { + const { resolver } = makeResolver(); + const controller = new AbortController(); + let added = 0; + let removed = 0; + const add = controller.signal.addEventListener.bind(controller.signal); + const remove = controller.signal.removeEventListener.bind(controller.signal); + controller.signal.addEventListener = (...args) => { + if (args[0] === 'abort') added += 1; + return add(...args); + }; + controller.signal.removeEventListener = (...args) => { + if (args[0] === 'abort') removed += 1; + return remove(...args); + }; + for (const query of ['Texas', 'Bagmati', 'Nowhere at all']) + await resolver.resolve({ query }, { signal: controller.signal }); + assert.ok(added > 0, 'the lookups listened while running'); + assert.equal(removed, added, 'every listener is detached once settled'); + + const aborted = AbortSignal.abort(); + let attached = 0; + aborted.addEventListener = () => { + attached += 1; + }; + const late = await resolver.resolve({ query: 'Punjab, Pakistan' }, { signal: aborted }); + assert.equal(late.code, 'CANCELLED'); + assert.equal(attached, 0, 'an aborted signal gets no listener'); +}); + test('resolver disposal aborts provider lifetime and prevents late store/cache writes', async () => { let release; let providerSignal;