From 77e4f1970103a9b47c36eefad8acc9b7ede1e2f2 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 6 Nov 2020 07:07:43 -0800 Subject: [PATCH] timers: cleanup abort listener on awaitable timers Co-authored-by: Benjamin Gruenbaum Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/36006 Reviewed-By: Benjamin Gruenbaum Reviewed-By: Matteo Collina Reviewed-By: Anna Henningsen --- lib/timers/promises.js | 42 +++++++++++++++--------- test/parallel/test-timers-promisified.js | 23 ++++++++++++- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/lib/timers/promises.js b/lib/timers/promises.js index ef1e6437d4f6ca..eba8c1e5036210 100644 --- a/lib/timers/promises.js +++ b/lib/timers/promises.js @@ -2,6 +2,7 @@ const { Promise, + PromisePrototypeFinally, PromiseReject, } = primordials; @@ -24,6 +25,13 @@ const lazyDOMException = hideStackFrames((message, name) => { return new DOMException(message, name); }); +function cancelListenerHandler(clear, reject) { + if (!this._destroyed) { + clear(this); + reject(lazyDOMException('The operation was aborted', 'AbortError')); + } +} + function setTimeout(after, value, options = {}) { const args = value !== undefined ? [value] : value; if (options == null || typeof options !== 'object') { @@ -58,20 +66,21 @@ function setTimeout(after, value, options = {}) { return PromiseReject( lazyDOMException('The operation was aborted', 'AbortError')); } - return new Promise((resolve, reject) => { + let oncancel; + const ret = new Promise((resolve, reject) => { const timeout = new Timeout(resolve, after, args, false, true); if (!ref) timeout.unref(); insert(timeout, timeout._idleTimeout); if (signal) { - signal.addEventListener('abort', () => { - if (!timeout._destroyed) { - // eslint-disable-next-line no-undef - clearTimeout(timeout); - reject(lazyDOMException('The operation was aborted', 'AbortError')); - } - }, { once: true }); + // eslint-disable-next-line no-undef + oncancel = cancelListenerHandler.bind(timeout, clearTimeout, reject); + signal.addEventListener('abort', oncancel); } }); + return oncancel !== undefined ? + PromisePrototypeFinally( + ret, + () => signal.removeEventListener('abort', oncancel)) : ret; } function setImmediate(value, options = {}) { @@ -107,19 +116,20 @@ function setImmediate(value, options = {}) { return PromiseReject( lazyDOMException('The operation was aborted', 'AbortError')); } - return new Promise((resolve, reject) => { + let oncancel; + const ret = new Promise((resolve, reject) => { const immediate = new Immediate(resolve, [value]); if (!ref) immediate.unref(); if (signal) { - signal.addEventListener('abort', () => { - if (!immediate._destroyed) { - // eslint-disable-next-line no-undef - clearImmediate(immediate); - reject(lazyDOMException('The operation was aborted', 'AbortError')); - } - }, { once: true }); + // eslint-disable-next-line no-undef + oncancel = cancelListenerHandler.bind(immediate, clearImmediate, reject); + signal.addEventListener('abort', oncancel); } }); + return oncancel !== undefined ? + PromisePrototypeFinally( + ret, + () => signal.removeEventListener('abort', oncancel)) : ret; } module.exports = { diff --git a/test/parallel/test-timers-promisified.js b/test/parallel/test-timers-promisified.js index bd52d2166cf88a..be73984b4fa602 100644 --- a/test/parallel/test-timers-promisified.js +++ b/test/parallel/test-timers-promisified.js @@ -1,4 +1,4 @@ -// Flags: --no-warnings +// Flags: --no-warnings --expose-internals 'use strict'; const common = require('../common'); const assert = require('assert'); @@ -6,6 +6,9 @@ const timers = require('timers'); const { promisify } = require('util'); const child_process = require('child_process'); +// TODO(benjamingr) - refactor to use getEventListeners when #35991 lands +const { NodeEventTarget } = require('internal/event_target'); + const timerPromises = require('timers/promises'); /* eslint-disable no-restricted-syntax */ @@ -92,6 +95,24 @@ process.on('multipleResolves', common.mustNotCall()); }); } +{ + // Check that timer adding signals does not leak handlers + const signal = new NodeEventTarget(); + signal.aborted = false; + setTimeout(0, null, { signal }).finally(common.mustCall(() => { + assert.strictEqual(signal.listenerCount('abort'), 0); + })); +} + +{ + // Check that timer adding signals does not leak handlers + const signal = new NodeEventTarget(); + signal.aborted = false; + setImmediate(0, { signal }).finally(common.mustCall(() => { + assert.strictEqual(signal.listenerCount('abort'), 0); + })); +} + { Promise.all( [1, '', false, Infinity].map((i) => assert.rejects(setImmediate(10, i)), {