Skip to content

Commit d047e99

Browse files
fix: use fasttimers for all connection timeouts (#3552) (#3675)
* fix: use fasttimers for all connection timeouts * Apply suggestions from code review * activate some tests * also use fastTimers in connect.js * fix tests * fix tests * fix tests * fix: use native timeouts for TIMEOUT_IDLE, rename TIMEOUT_IDLE to TIMEOUT_KEEP_ALIVE (#3554) * fix: use native timeouts for TIMEOUT_IDLE, rename TIMEOUT_IDLE to TIMEOUT_KEEP_ALIVE * ensure that fast timers and native timers are set properly * . * . * rename import * more informative connection error * ignore request-timeout binary file, rename clearAll to reset * fix * add test * use queueMicrotask earlier in the socket callbacks (cherry picked from commit dca0aa0) Co-authored-by: Aras Abbasi <[email protected]>
1 parent a7bffd4 commit d047e99

File tree

8 files changed

+191
-153
lines changed

8 files changed

+191
-153
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,6 @@ undici-fetch.js
8484
.npmrc
8585

8686
.tap
87+
88+
# File generated by /test/request-timeout.js
89+
test/request-timeout.10mb.bin

.npmignore

+3
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ lib/llhttp/llhttp.wasm
1111
!index.d.ts
1212
!docs/docs/**/*
1313
!scripts/strip-comments.js
14+
15+
# File generated by /test/request-timeout.js
16+
test/request-timeout.10mb.bin

lib/core/connect.js

+48-21
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ const util = require('./util')
66
const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')
77
const timers = require('../util/timers')
88

9+
function noop () {}
10+
911
let tls // include tls conditionally since it is not always available
1012

1113
// TODO: session re-use does not wait for the first
@@ -96,6 +98,8 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
9698

9799
const session = customSession || sessionCache.get(sessionKey) || null
98100

101+
port = port || 443
102+
99103
socket = tls.connect({
100104
highWaterMark: 16384, // TLS in node can't have bigger HWM anyway...
101105
...options,
@@ -105,7 +109,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
105109
// TODO(HTTP/2): Add support for h2c
106110
ALPNProtocols: allowH2 ? ['http/1.1', 'h2'] : ['http/1.1'],
107111
socket: httpSocket, // upgrade socket connection
108-
port: port || 443,
112+
port,
109113
host: hostname
110114
})
111115

@@ -116,11 +120,14 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
116120
})
117121
} else {
118122
assert(!httpSocket, 'httpSocket can only be sent on TLS update')
123+
124+
port = port || 80
125+
119126
socket = net.connect({
120127
highWaterMark: 64 * 1024, // Same as nodejs fs streams.
121128
...options,
122129
localAddress,
123-
port: port || 80,
130+
port,
124131
host: hostname
125132
})
126133
}
@@ -131,12 +138,12 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
131138
socket.setKeepAlive(true, keepAliveInitialDelay)
132139
}
133140

134-
const cancelConnectTimeout = setupConnectTimeout(new WeakRef(socket), timeout)
141+
const clearConnectTimeout = setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port })
135142

136143
socket
137144
.setNoDelay(true)
138145
.once(protocol === 'https:' ? 'secureConnect' : 'connect', function () {
139-
cancelConnectTimeout()
146+
queueMicrotask(clearConnectTimeout)
140147

141148
if (callback) {
142149
const cb = callback
@@ -145,7 +152,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
145152
}
146153
})
147154
.on('error', function (err) {
148-
cancelConnectTimeout()
155+
queueMicrotask(clearConnectTimeout)
149156

150157
if (callback) {
151158
const cb = callback
@@ -158,50 +165,70 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
158165
}
159166
}
160167

168+
/**
169+
* @param {WeakRef<net.Socket>} socketWeakRef
170+
* @param {object} opts
171+
* @param {number} opts.timeout
172+
* @param {string} opts.hostname
173+
* @param {number} opts.port
174+
* @returns {() => void}
175+
*/
161176
const setupConnectTimeout = process.platform === 'win32'
162-
? (socket, timeout) => {
163-
if (!timeout) {
164-
return () => { }
177+
? (socketWeakRef, opts) => {
178+
if (!opts.timeout) {
179+
return noop
165180
}
166181

167182
let s1 = null
168183
let s2 = null
169-
const timer = timers.setTimeout(() => {
184+
const fastTimer = timers.setFastTimeout(() => {
170185
// setImmediate is added to make sure that we prioritize socket error events over timeouts
171186
s1 = setImmediate(() => {
172187
// Windows needs an extra setImmediate probably due to implementation differences in the socket logic
173-
s2 = setImmediate(() => onConnectTimeout(socket.deref()))
188+
s2 = setImmediate(() => onConnectTimeout(socketWeakRef.deref(), opts))
174189
})
175-
}, timeout)
190+
}, opts.timeout)
176191
return () => {
177-
timers.clearTimeout(timer)
192+
timers.clearFastTimeout(fastTimer)
178193
clearImmediate(s1)
179194
clearImmediate(s2)
180195
}
181196
}
182-
: (socket, timeout) => {
183-
if (!timeout) {
184-
return () => { }
197+
: (socketWeakRef, opts) => {
198+
if (!opts.timeout) {
199+
return noop
185200
}
186201

187202
let s1 = null
188-
const timer = timers.setTimeout(() => {
203+
const fastTimer = timers.setFastTimeout(() => {
189204
// setImmediate is added to make sure that we prioritize socket error events over timeouts
190205
s1 = setImmediate(() => {
191-
onConnectTimeout(socket.deref())
206+
onConnectTimeout(socketWeakRef.deref(), opts)
192207
})
193-
}, timeout)
208+
}, opts.timeout)
194209
return () => {
195-
timers.clearTimeout(timer)
210+
timers.clearFastTimeout(fastTimer)
196211
clearImmediate(s1)
197212
}
198213
}
199214

200-
function onConnectTimeout (socket) {
215+
/**
216+
* @param {net.Socket} socket
217+
* @param {object} opts
218+
* @param {number} opts.timeout
219+
* @param {string} opts.hostname
220+
* @param {number} opts.port
221+
*/
222+
function onConnectTimeout (socket, opts) {
201223
let message = 'Connect Timeout Error'
202224
if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) {
203-
message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')})`
225+
message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},`
226+
} else {
227+
message += ` (attempted address: ${opts.hostname}:${opts.port},`
204228
}
229+
230+
message += ` timeout: ${opts.timeout}ms)`
231+
205232
util.destroy(socket, new ConnectTimeoutError(message))
206233
}
207234

lib/dispatcher/client-h1.js

+35-14
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,17 @@ let currentBufferRef = null
131131
let currentBufferSize = 0
132132
let currentBufferPtr = null
133133

134-
const TIMEOUT_HEADERS = 1
135-
const TIMEOUT_BODY = 2
136-
const TIMEOUT_IDLE = 3
134+
const USE_NATIVE_TIMER = 0
135+
const USE_FAST_TIMER = 1
136+
137+
// Use fast timers for headers and body to take eventual event loop
138+
// latency into account.
139+
const TIMEOUT_HEADERS = 2 | USE_FAST_TIMER
140+
const TIMEOUT_BODY = 4 | USE_FAST_TIMER
141+
142+
// Use native timers to ignore event loop latency for keep-alive
143+
// handling.
144+
const TIMEOUT_KEEP_ALIVE = 8 | USE_NATIVE_TIMER
137145

138146
class Parser {
139147
constructor (client, socket, { exports }) {
@@ -165,25 +173,38 @@ class Parser {
165173
}
166174

167175
setTimeout (delay, type) {
168-
this.timeoutType = type
169-
if (delay !== this.timeoutValue) {
170-
this.timeout && timers.clearTimeout(this.timeout)
176+
// If the existing timer and the new timer are of different timer type
177+
// (fast or native) or have different delay, we need to clear the existing
178+
// timer and set a new one.
179+
if (
180+
delay !== this.timeoutValue ||
181+
(type & USE_FAST_TIMER) ^ (this.timeoutType & USE_FAST_TIMER)
182+
) {
183+
// If a timeout is already set, clear it with clearTimeout of the fast
184+
// timer implementation, as it can clear fast and native timers.
185+
if (this.timeout) {
186+
timers.clearTimeout(this.timeout)
187+
this.timeout = null
188+
}
189+
171190
if (delay) {
172-
this.timeout = timers.setTimeout(onParserTimeout, delay, new WeakRef(this))
173-
// istanbul ignore else: only for jest
174-
if (this.timeout.unref) {
191+
if (type & USE_FAST_TIMER) {
192+
this.timeout = timers.setFastTimeout(onParserTimeout, delay, new WeakRef(this))
193+
} else {
194+
this.timeout = setTimeout(onParserTimeout, delay, new WeakRef(this))
175195
this.timeout.unref()
176196
}
177-
} else {
178-
this.timeout = null
179197
}
198+
180199
this.timeoutValue = delay
181200
} else if (this.timeout) {
182201
// istanbul ignore else: only for jest
183202
if (this.timeout.refresh) {
184203
this.timeout.refresh()
185204
}
186205
}
206+
207+
this.timeoutType = type
187208
}
188209

189210
resume () {
@@ -624,7 +645,7 @@ function onParserTimeout (parser) {
624645
if (!paused) {
625646
util.destroy(socket, new BodyTimeoutError())
626647
}
627-
} else if (timeoutType === TIMEOUT_IDLE) {
648+
} else if (timeoutType === TIMEOUT_KEEP_ALIVE) {
628649
assert(client[kRunning] === 0 && client[kKeepAliveTimeoutValue])
629650
util.destroy(socket, new InformationalError('socket idle timeout'))
630651
}
@@ -802,8 +823,8 @@ function resumeH1 (client) {
802823
}
803824

804825
if (client[kSize] === 0) {
805-
if (socket[kParser].timeoutType !== TIMEOUT_IDLE) {
806-
socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_IDLE)
826+
if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) {
827+
socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE)
807828
}
808829
} else if (client[kRunning] > 0 && socket[kParser].statusCode < 200) {
809830
if (socket[kParser].timeoutType !== TIMEOUT_HEADERS) {

lib/util/timers.js

+48-1
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ module.exports = {
347347
* The clearTimeout method cancels an instantiated Timer previously created
348348
* by calling setTimeout.
349349
*
350-
* @param {FastTimer} timeout
350+
* @param {NodeJS.Timeout|FastTimer} timeout
351351
*/
352352
clearTimeout (timeout) {
353353
// If the timeout is a FastTimer, call its own clear method.
@@ -362,6 +362,29 @@ module.exports = {
362362
nativeClearTimeout(timeout)
363363
}
364364
},
365+
/**
366+
* The setFastTimeout() method sets a fastTimer which executes a function once
367+
* the timer expires.
368+
* @param {Function} callback A function to be executed after the timer
369+
* expires.
370+
* @param {number} delay The time, in milliseconds that the timer should
371+
* wait before the specified function or code is executed.
372+
* @param {*} [arg] An optional argument to be passed to the callback function
373+
* when the timer expires.
374+
* @returns {FastTimer}
375+
*/
376+
setFastTimeout (callback, delay, arg) {
377+
return new FastTimer(callback, delay, arg)
378+
},
379+
/**
380+
* The clearTimeout method cancels an instantiated FastTimer previously
381+
* created by calling setFastTimeout.
382+
*
383+
* @param {FastTimer} timeout
384+
*/
385+
clearFastTimeout (timeout) {
386+
timeout.clear()
387+
},
365388
/**
366389
* The now method returns the value of the internal fast timer clock.
367390
*
@@ -370,6 +393,30 @@ module.exports = {
370393
now () {
371394
return fastNow
372395
},
396+
/**
397+
* Trigger the onTick function to process the fastTimers array.
398+
* Exported for testing purposes only.
399+
* Marking as deprecated to discourage any use outside of testing.
400+
* @deprecated
401+
* @param {number} [delay=0] The delay in milliseconds to add to the now value.
402+
*/
403+
tick (delay = 0) {
404+
fastNow += delay - RESOLUTION_MS + 1
405+
onTick()
406+
onTick()
407+
},
408+
/**
409+
* Reset FastTimers.
410+
* Exported for testing purposes only.
411+
* Marking as deprecated to discourage any use outside of testing.
412+
* @deprecated
413+
*/
414+
reset () {
415+
fastNow = 0
416+
fastTimers.length = 0
417+
clearTimeout(fastNowTimeout)
418+
fastNowTimeout = null
419+
},
373420
/**
374421
* Exporting for testing purposes only.
375422
* Marking as deprecated to discourage any use outside of testing.

test/connect-timeout.js

+8-3
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ const skip = !!process.env.CITGM
1010

1111
// Using describe instead of test to avoid the timeout
1212
describe('prioritize socket errors over timeouts', { skip }, async () => {
13-
const t = tspl({ ...assert, after: () => {} }, { plan: 1 })
13+
const t = tspl({ ...assert, after: () => {} }, { plan: 2 })
1414
const client = new Pool('http://foorbar.invalid:1234', { connectTimeout: 1 })
1515

1616
client.request({ method: 'GET', path: '/foobar' })
1717
.then(() => t.fail())
1818
.catch((err) => {
19+
t.strictEqual(err.code, 'ENOTFOUND')
1920
t.strictEqual(err.code !== 'UND_ERR_CONNECT_TIMEOUT', true)
2021
})
2122

@@ -32,7 +33,7 @@ net.connect = function (options) {
3233
}
3334

3435
test('connect-timeout', { skip }, async t => {
35-
t = tspl(t, { plan: 1 })
36+
t = tspl(t, { plan: 3 })
3637

3738
const client = new Client('http://localhost:9000', {
3839
connectTimeout: 1e3
@@ -48,14 +49,16 @@ test('connect-timeout', { skip }, async t => {
4849
method: 'GET'
4950
}, (err) => {
5051
t.ok(err instanceof errors.ConnectTimeoutError)
52+
t.strictEqual(err.code, 'UND_ERR_CONNECT_TIMEOUT')
53+
t.strictEqual(err.message, 'Connect Timeout Error (attempted address: localhost:9000, timeout: 1000ms)')
5154
clearTimeout(timeout)
5255
})
5356

5457
await t.completed
5558
})
5659

5760
test('connect-timeout', { skip }, async t => {
58-
t = tspl(t, { plan: 1 })
61+
t = tspl(t, { plan: 3 })
5962

6063
const client = new Pool('http://localhost:9000', {
6164
connectTimeout: 1e3
@@ -71,6 +74,8 @@ test('connect-timeout', { skip }, async t => {
7174
method: 'GET'
7275
}, (err) => {
7376
t.ok(err instanceof errors.ConnectTimeoutError)
77+
t.strictEqual(err.code, 'UND_ERR_CONNECT_TIMEOUT')
78+
t.strictEqual(err.message, 'Connect Timeout Error (attempted address: localhost:9000, timeout: 1000ms)')
7479
clearTimeout(timeout)
7580
})
7681

test/issue-3356.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const { tspl } = require('@matteo.collina/tspl')
44
const { test, after } = require('node:test')
55
const { createServer } = require('node:http')
66
const { once } = require('node:events')
7-
7+
const { tick: fastTimersTick } = require('../lib/util/timers')
88
const { fetch, Agent, RetryAgent } = require('..')
99

1010
test('https://github.com/nodejs/undici/issues/3356', async (t) => {
@@ -42,6 +42,9 @@ test('https://github.com/nodejs/undici/issues/3356', async (t) => {
4242
const response = await fetch(`http://localhost:${server.address().port}`, {
4343
dispatcher: agent
4444
})
45+
46+
fastTimersTick()
47+
4548
setTimeout(async () => {
4649
try {
4750
t.equal(response.status, 200)

0 commit comments

Comments
 (0)