Skip to content

Commit 0c7c4af

Browse files
jasnelldanielleadams
authored andcommitted
lib: add AbortSignal.timeout
Refs: whatwg/dom#1032 Signed-off-by: James M Snell <[email protected]> PR-URL: #40899 Reviewed-By: Michaël Zasso <[email protected]> Reviewed-By: Matteo Collina <[email protected]>
1 parent 68c4820 commit 0c7c4af

File tree

3 files changed

+174
-3
lines changed

3 files changed

+174
-3
lines changed

doc/api/globals.md

+11
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,17 @@ changes:
9898

9999
Returns a new already aborted `AbortSignal`.
100100

101+
#### Static method: `AbortSignal.timeout(delay)`
102+
103+
<!-- YAML
104+
added: REPLACEME
105+
-->
106+
107+
* `delay` {number} The number of milliseconds to wait before triggering
108+
the AbortSignal.
109+
110+
Returns a new `AbortSignal` which will be aborted in `delay` milliseconds.
111+
101112
#### Event: `'abort'`
102113

103114
<!-- YAML

lib/internal/abort_controller.js

+84-1
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,20 @@ const {
88
ObjectDefineProperties,
99
ObjectSetPrototypeOf,
1010
ObjectDefineProperty,
11+
SafeFinalizationRegistry,
12+
SafeSet,
1113
Symbol,
1214
SymbolToStringTag,
15+
WeakRef,
1316
} = primordials;
1417

1518
const {
1619
defineEventHandler,
1720
EventTarget,
1821
Event,
19-
kTrustEvent
22+
kTrustEvent,
23+
kNewListener,
24+
kRemoveListener,
2025
} = require('internal/event_target');
2126
const {
2227
customInspectSymbol,
@@ -29,8 +34,26 @@ const {
2934
}
3035
} = require('internal/errors');
3136

37+
const {
38+
validateUint32,
39+
} = require('internal/validators');
40+
41+
const {
42+
DOMException,
43+
} = internalBinding('messaging');
44+
45+
const {
46+
clearTimeout,
47+
setTimeout,
48+
} = require('timers');
49+
3250
const kAborted = Symbol('kAborted');
3351
const kReason = Symbol('kReason');
52+
const kTimeout = Symbol('kTimeout');
53+
54+
const timeOutSignals = new SafeSet();
55+
56+
const clearTimeoutRegistry = new SafeFinalizationRegistry(clearTimeout);
3457

3558
function customInspect(self, obj, depth, options) {
3659
if (depth < 0)
@@ -48,6 +71,30 @@ function validateAbortSignal(obj) {
4871
throw new ERR_INVALID_THIS('AbortSignal');
4972
}
5073

74+
// Because the AbortSignal timeout cannot be canceled, we don't want the
75+
// presence of the timer alone to keep the AbortSignal from being garbage
76+
// collected if it otherwise no longer accessible. We also don't want the
77+
// timer to keep the Node.js process open on it's own. Therefore, we wrap
78+
// the AbortSignal in a WeakRef and have the setTimeout callback close
79+
// over the WeakRef rather than directly over the AbortSignal, and we unref
80+
// the created timer object. Separately, we add the signal to a
81+
// FinalizerRegistry that will clear the timeout when the signal is gc'd.
82+
function setWeakAbortSignalTimeout(weakRef, delay) {
83+
const timeout = setTimeout(() => {
84+
const signal = weakRef.deref();
85+
if (signal !== undefined) {
86+
timeOutSignals.delete(signal);
87+
abortSignal(
88+
signal,
89+
new DOMException(
90+
'The operation was aborted due to timeout',
91+
'TimeoutError'));
92+
}
93+
}, delay);
94+
timeout.unref();
95+
return timeout;
96+
}
97+
5198
class AbortSignal extends EventTarget {
5299
constructor() {
53100
throw new ERR_ILLEGAL_CONSTRUCTOR();
@@ -82,6 +129,42 @@ class AbortSignal extends EventTarget {
82129
static abort(reason) {
83130
return createAbortSignal(true, reason);
84131
}
132+
133+
/**
134+
* @param {number} delay
135+
* @returns {AbortSignal}
136+
*/
137+
static timeout(delay) {
138+
validateUint32(delay, 'delay', true);
139+
const signal = createAbortSignal();
140+
signal[kTimeout] = true;
141+
clearTimeoutRegistry.register(
142+
signal,
143+
setWeakAbortSignalTimeout(new WeakRef(signal), delay));
144+
return signal;
145+
}
146+
147+
[kNewListener](size, type, listener, once, capture, passive, weak) {
148+
super[kNewListener](size, type, listener, once, capture, passive, weak);
149+
if (this[kTimeout] &&
150+
type === 'abort' &&
151+
!this.aborted &&
152+
!weak &&
153+
size === 1) {
154+
// If this is a timeout signal, and we're adding a non-weak abort
155+
// listener, then we don't want it to be gc'd while the listener
156+
// is attached and the timer still hasn't fired. So, we retain a
157+
// strong ref that is held for as long as the listener is registered.
158+
timeOutSignals.add(this);
159+
}
160+
}
161+
162+
[kRemoveListener](size, type, listener, capture) {
163+
super[kRemoveListener](size, type, listener, capture);
164+
if (this[kTimeout] && type === 'abort' && size === 0) {
165+
timeOutSignals.delete(this);
166+
}
167+
}
85168
}
86169

87170
ObjectDefineProperties(AbortSignal.prototype, {

test/parallel/test-abortcontroller.js

+79-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1-
// Flags: --no-warnings
1+
// Flags: --no-warnings --expose-gc --expose-internals
22
'use strict';
33

44
const common = require('../common');
55
const { inspect } = require('util');
66

7-
const { ok, strictEqual, throws } = require('assert');
7+
const {
8+
ok,
9+
notStrictEqual,
10+
strictEqual,
11+
throws,
12+
} = require('assert');
13+
14+
const {
15+
kWeakHandler,
16+
} = require('internal/event_target');
17+
18+
const { setTimeout: sleep } = require('timers/promises');
819

920
{
1021
// Tests that abort is fired with the correct event type on AbortControllers
@@ -153,3 +164,69 @@ const { ok, strictEqual, throws } = require('assert');
153164
const signal = AbortSignal.abort('reason');
154165
strictEqual(signal.reason, 'reason');
155166
}
167+
168+
{
169+
// Test AbortSignal timeout
170+
const signal = AbortSignal.timeout(10);
171+
ok(!signal.aborted);
172+
setTimeout(common.mustCall(() => {
173+
ok(signal.aborted);
174+
strictEqual(signal.reason.name, 'TimeoutError');
175+
strictEqual(signal.reason.code, 23);
176+
}), 20);
177+
}
178+
179+
{
180+
(async () => {
181+
// Test AbortSignal timeout doesn't prevent the signal
182+
// from being garbage collected.
183+
let ref;
184+
{
185+
ref = new globalThis.WeakRef(AbortSignal.timeout(1_200_000));
186+
}
187+
188+
await sleep(10);
189+
globalThis.gc();
190+
strictEqual(ref.deref(), undefined);
191+
})().then(common.mustCall());
192+
193+
(async () => {
194+
// Test that an AbortSignal with a timeout is not gc'd while
195+
// there is an active listener on it.
196+
let ref;
197+
function handler() {}
198+
{
199+
ref = new globalThis.WeakRef(AbortSignal.timeout(1_200_000));
200+
ref.deref().addEventListener('abort', handler);
201+
}
202+
203+
await sleep(10);
204+
globalThis.gc();
205+
notStrictEqual(ref.deref(), undefined);
206+
ok(ref.deref() instanceof AbortSignal);
207+
208+
ref.deref().removeEventListener('abort', handler);
209+
210+
await sleep(10);
211+
globalThis.gc();
212+
strictEqual(ref.deref(), undefined);
213+
})().then(common.mustCall());
214+
215+
(async () => {
216+
// If the event listener is weak, however, it should not prevent gc
217+
let ref;
218+
function handler() {}
219+
{
220+
ref = new globalThis.WeakRef(AbortSignal.timeout(1_200_000));
221+
ref.deref().addEventListener('abort', handler, { [kWeakHandler]: {} });
222+
}
223+
224+
await sleep(10);
225+
globalThis.gc();
226+
strictEqual(ref.deref(), undefined);
227+
})().then(common.mustCall());
228+
229+
// Setting a long timeout (20 minutes here) should not
230+
// keep the Node.js process open (the timer is unref'd)
231+
AbortSignal.timeout(1_200_000);
232+
}

0 commit comments

Comments
 (0)