Skip to content

Commit f57411b

Browse files
mcollinaanonrig
andauthored
perf(h1): drop idle-socket timer floor with a ref'd setImmediate (#5707) (#5769)
(cherry picked from commit 8c9182e) Signed-off-by: Matteo Collina <hello@matteocollina.com> Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
1 parent 3c67265 commit f57411b

3 files changed

Lines changed: 174 additions & 4 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use strict'
2+
3+
import { createServer } from 'node:http'
4+
import { Agent, fetch } from '../../index.js'
5+
6+
const ITERATIONS = Number(process.env.SAMPLES ?? 2000)
7+
const WARMUP = 300
8+
9+
const server = createServer((req, res) => {
10+
res.writeHead(200, { 'content-type': 'application/json' })
11+
res.end('{"ok":1}')
12+
})
13+
14+
server.keepAliveTimeout = 65_000
15+
16+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
17+
18+
const { port } = server.address()
19+
const url = `http://127.0.0.1:${port}/`
20+
const agent = new Agent({
21+
keepAliveTimeout: 60_000,
22+
connections: 1,
23+
pipelining: 1
24+
})
25+
26+
for (let i = 0; i < WARMUP; i++) {
27+
await (await fetch(url, { dispatcher: agent })).text()
28+
}
29+
30+
const times = new Array(ITERATIONS)
31+
for (let i = 0; i < ITERATIONS; i++) {
32+
const t0 = process.hrtime.bigint()
33+
await (await fetch(url, { dispatcher: agent })).text()
34+
times[i] = Number(process.hrtime.bigint() - t0)
35+
}
36+
37+
times.sort((a, b) => a - b)
38+
const pct = (p) => times[Math.min(ITERATIONS - 1, Math.floor(ITERATIONS * p))] / 1e6
39+
40+
console.log(JSON.stringify({
41+
iterations: ITERATIONS,
42+
p50_ms: Number(pct(0.5).toFixed(3)),
43+
p90_ms: Number(pct(0.9).toFixed(3)),
44+
p99_ms: Number(pct(0.99).toFixed(3))
45+
}))
46+
47+
await agent.close()
48+
server.close()

‎lib/dispatcher/client-h1.js‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,7 +1012,7 @@ function onSocketClose () {
10121012

10131013
function clearIdleSocketValidation (socket) {
10141014
if (socket[kIdleSocketValidationTimeout]) {
1015-
clearTimeout(socket[kIdleSocketValidationTimeout])
1015+
clearImmediate(socket[kIdleSocketValidationTimeout])
10161016
socket[kIdleSocketValidationTimeout] = null
10171017
}
10181018

@@ -1021,15 +1021,23 @@ function clearIdleSocketValidation (socket) {
10211021

10221022
function scheduleIdleSocketValidation (client, socket) {
10231023
socket[kIdleSocketValidation] = 1
1024-
socket[kIdleSocketValidationTimeout] = setTimeout(() => {
1024+
// Yield to the check phase (after poll) so unsolicited bytes / FIN / RST
1025+
// already pending on this idle keep-alive socket are processed before the
1026+
// next request is written (GHSA-35p6-xmwp-9g52).
1027+
//
1028+
// setTimeout(0) pays Node's ~1ms timer floor on every sequential reuse
1029+
// (#5493). setImmediate avoids that, but an *unref'd* Immediate lets poll
1030+
// block for ~500ms when the event loop is otherwise idle (#5600 / #5606).
1031+
// A ref'd Immediate both keeps the pending request alive and makes poll
1032+
// return immediately — the hybrid those issues asked for.
1033+
socket[kIdleSocketValidationTimeout] = setImmediate(() => {
10251034
socket[kIdleSocketValidationTimeout] = null
10261035
socket[kIdleSocketValidation] = 2
10271036

10281037
if (client[kSocket] === socket && !socket.destroyed) {
10291038
client[kResume]()
10301039
}
1031-
}, 0)
1032-
socket[kIdleSocketValidationTimeout].unref?.()
1040+
})
10331041
}
10341042

10351043
/**

‎test/node-test/keep-alive-reuse.js‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use strict'
2+
3+
const { test } = require('node:test')
4+
const assert = require('node:assert')
5+
const { createServer } = require('node:http')
6+
const { once } = require('node:events')
7+
const { Agent, Pool, fetch } = require('../..')
8+
9+
// Regression for #5600 / #5606:
10+
// Reusing an idle keep-alive socket must not stall behind the poll phase.
11+
// Progress is asserted via logical I/O events (server 'connection' / 'request'),
12+
// not wall-clock thresholds. If idle-socket validation is deferred with an
13+
// unref'd setImmediate, the poll phase blocks on the re-ref'd socket and this
14+
// test hits the timeout instead of completing.
15+
//
16+
// The buggy setImmediate path stalls ~TICK_MS (~499ms) per reuse when the
17+
// event loop is idle (woken only by undici's fast-timer tick). Five reuses
18+
// therefore take well over 1s when regressed, and a few dozen ms when fixed.
19+
20+
const REUSES = 5
21+
22+
test('reusing an idle keep-alive socket must not stall', { timeout: 1000 }, async (t) => {
23+
let connections = 0
24+
25+
const server = createServer((req, res) => {
26+
res.writeHead(200, { 'content-length': 2 })
27+
res.end('ok')
28+
})
29+
30+
server.on('connection', () => {
31+
connections++
32+
})
33+
34+
server.listen(0)
35+
await once(server, 'listening')
36+
37+
const pool = new Pool(`http://127.0.0.1:${server.address().port}`, {
38+
connections: 1
39+
})
40+
41+
t.after(async () => {
42+
await pool.close()
43+
server.close()
44+
})
45+
46+
// Establish the keep-alive connection.
47+
{
48+
const res = await pool.request({ path: '/0', method: 'GET' })
49+
assert.strictEqual(await res.body.text(), 'ok')
50+
}
51+
assert.strictEqual(connections, 1)
52+
53+
// Each reuse is gated on the server observing the request. Between
54+
// iterations the client socket is idle/unref'd, which is the state that
55+
// triggers idle-socket validation on the next dispatch.
56+
for (let i = 1; i <= REUSES; i++) {
57+
const requested = once(server, 'request')
58+
const resPromise = pool.request({ path: `/${i}`, method: 'GET' })
59+
// Suppress unhandled rejection if the test times out mid-request.
60+
resPromise.catch(() => {})
61+
62+
await requested
63+
64+
const res = await resPromise
65+
assert.strictEqual(await res.body.text(), 'ok')
66+
assert.strictEqual(connections, 1, 'keep-alive socket must be reused')
67+
}
68+
})
69+
70+
test('fetch reusing an idle keep-alive socket must not stall', { timeout: 1000 }, async (t) => {
71+
let connections = 0
72+
73+
const server = createServer((req, res) => {
74+
res.writeHead(200, { 'content-length': 2 })
75+
res.end('ok')
76+
})
77+
78+
server.on('connection', () => {
79+
connections++
80+
})
81+
82+
server.listen(0)
83+
await once(server, 'listening')
84+
85+
const url = `http://127.0.0.1:${server.address().port}`
86+
const agent = new Agent({
87+
connections: 1,
88+
pipelining: 1,
89+
keepAliveTimeout: 60_000
90+
})
91+
92+
t.after(async () => {
93+
await agent.close()
94+
server.close()
95+
})
96+
97+
{
98+
const res = await fetch(`${url}/0`, { dispatcher: agent })
99+
assert.strictEqual(await res.text(), 'ok')
100+
}
101+
assert.strictEqual(connections, 1)
102+
103+
for (let i = 1; i <= REUSES; i++) {
104+
const requested = once(server, 'request')
105+
const resPromise = fetch(`${url}/${i}`, { dispatcher: agent })
106+
resPromise.catch(() => {})
107+
108+
await requested
109+
110+
const res = await resPromise
111+
assert.strictEqual(await res.text(), 'ok')
112+
assert.strictEqual(connections, 1, 'keep-alive socket must be reused')
113+
}
114+
})

0 commit comments

Comments
 (0)