Skip to content

Commit 1c5dc1a

Browse files
authored
test: add unexpected disconnect guards to more client test files (#4844)
1 parent 2885361 commit 1c5dc1a

22 files changed

Lines changed: 324 additions & 7 deletions

docs/docs/best-practices/writing-tests.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,46 @@ const agent = new Agent({
1818

1919
setGlobalDispatcher(agent)
2020
```
21+
22+
## Guarding against unexpected disconnects
23+
24+
Undici's `Client` automatically reconnects after a socket error. This means
25+
a test can silently disconnect, reconnect, and still pass. Unfortunately,
26+
this could mask bugs like unexpected parser errors or protocol violations.
27+
To catch these silent reconnections, add a disconnect guard after creating
28+
a `Client`:
29+
30+
```js
31+
const { Client } = require('undici')
32+
const { test, after } = require('node:test')
33+
const { tspl } = require('@matteo.collina/tspl')
34+
35+
test('example with disconnect guard', async (t) => {
36+
t = tspl(t, { plan: 1 })
37+
38+
const client = new Client('http://localhost:3000')
39+
after(() => client.close())
40+
41+
client.on('disconnect', () => {
42+
if (!client.closed && !client.destroyed) {
43+
t.fail('unexpected disconnect')
44+
}
45+
})
46+
47+
// ... test logic ...
48+
})
49+
```
50+
51+
`client.close()` and `client.destroy()` both emit `'disconnect'` events, but
52+
those are expected. The guard only fails when a disconnect happens during the
53+
active test (i.e., `!client.closed && !client.destroyed` is true).
54+
55+
Skip the guard for tests where a disconnect is expected behavior, such as:
56+
57+
- Signal aborts (`signal.emit('abort')`, `ac.abort()`)
58+
- Server-side destruction (`res.destroy()`, `req.socket.destroy()`)
59+
- Client-side body destruction mid-stream (`data.body.destroy()`)
60+
- Timeout errors (`HeadersTimeoutError`, `BodyTimeoutError`)
61+
- Successful upgrades (the socket is detached from the `Client`)
62+
- Retry/reconnect tests where the disconnect triggers the retry
63+
- HTTP parser errors from malformed responses (`HTTPParserError`)

test/client-timeout.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ test('parser resume with no body timeout', async (t) => {
180180
})
181181
after(() => client.destroy())
182182

183+
client.on('disconnect', () => {
184+
if (!client.closed && !client.destroyed) {
185+
t.fail('unexpected disconnect')
186+
}
187+
})
188+
183189
client.dispatch({
184190
path: '/',
185191
method: 'GET'

test/client-write-max-listeners.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ test('socket close listener does not leak', async (t) => {
4848
const client = new Client(`http://localhost:${server.address().port}`)
4949
after(() => client.destroy())
5050

51+
client.on('disconnect', () => {
52+
if (!client.closed && !client.destroyed) {
53+
t.fail('unexpected disconnect')
54+
}
55+
})
56+
5157
for (let n = 0; n < 16; ++n) {
5258
client.request({ path: '/', method: 'GET', body: makeBody() }, onRequest)
5359
}

test/connect-pre-shared-session.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ test('custom session passed to client will be used in tls connect call', async (
3131
})
3232
after(() => client.close())
3333

34+
client.on('disconnect', () => {
35+
if (!client.closed && !client.destroyed) {
36+
t.fail('unexpected disconnect')
37+
}
38+
})
39+
3440
const { statusCode, headers, body } = await client.request({
3541
path: '/',
3642
method: 'GET'

test/content-length.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@ test('request streaming no body data when content-length=0', async (t) => {
209209
const client = new Client(`http://localhost:${server.address().port}`)
210210
after(() => client.close())
211211

212+
client.on('disconnect', () => {
213+
if (!client.closed && !client.destroyed) {
214+
t.fail('unexpected disconnect')
215+
}
216+
})
217+
212218
client.request({
213219
path: '/',
214220
method: 'PUT',
@@ -278,6 +284,12 @@ test('request streaming with Readable.from(buf)', async (t) => {
278284
const client = new Client(`http://localhost:${server.address().port}`)
279285
after(() => client.close())
280286

287+
client.on('disconnect', () => {
288+
if (!client.closed && !client.destroyed) {
289+
t.fail('unexpected disconnect')
290+
}
291+
})
292+
281293
client.request({
282294
path: '/',
283295
method: 'PUT',

test/fetch/encoding.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,18 @@ describe('content-encoding chain limit', () => {
100100
server = createServer({
101101
noDelay: true
102102
}, (req, res) => {
103+
res.socket.setNoDelay(true)
103104
const encodingCount = parseInt(req.headers['x-encoding-count'] || '1', 10)
104105
const encodings = Array(encodingCount).fill('identity').join(', ')
105106

106107
res.writeHead(200, {
107108
'Content-Encoding': encodings,
108109
'Content-Type': 'text/plain'
109110
})
111+
res.flushHeaders()
110112
res.end('test')
111113
})
112-
await once(server.listen(0), 'listening')
114+
await once(server.listen(0, '127.0.0.1'), 'listening')
113115
})
114116

115117
after(() => {
@@ -118,10 +120,10 @@ describe('content-encoding chain limit', () => {
118120
})
119121

120122
test(`should allow exactly ${MAX_CONTENT_ENCODINGS} content-encodings`, async (t) => {
121-
const client = new Client(`http://localhost:${server.address().port}`)
123+
const client = new Client(`http://127.0.0.1:${server.address().port}`)
122124
t.after(() => client.close())
123125

124-
const response = await fetch(`http://localhost:${server.address().port}`, {
126+
const response = await fetch(`http://127.0.0.1:${server.address().port}`, {
125127
dispatcher: client,
126128
keepalive: false,
127129
headers: { 'x-encoding-count': String(MAX_CONTENT_ENCODINGS) }
@@ -133,11 +135,11 @@ describe('content-encoding chain limit', () => {
133135
})
134136

135137
test(`should reject more than ${MAX_CONTENT_ENCODINGS} content-encodings`, async (t) => {
136-
const client = new Client(`http://localhost:${server.address().port}`)
138+
const client = new Client(`http://127.0.0.1:${server.address().port}`)
137139
t.after(() => client.close())
138140

139141
await t.assert.rejects(
140-
fetch(`http://localhost:${server.address().port}`, {
142+
fetch(`http://127.0.0.1:${server.address().port}`, {
141143
dispatcher: client,
142144
keepalive: false,
143145
headers: { 'x-encoding-count': String(MAX_CONTENT_ENCODINGS + 1) }
@@ -150,11 +152,11 @@ describe('content-encoding chain limit', () => {
150152
})
151153

152154
test('should reject excessive content-encoding chains', async (t) => {
153-
const client = new Client(`http://localhost:${server.address().port}`)
155+
const client = new Client(`http://127.0.0.1:${server.address().port}`)
154156
t.after(() => client.close())
155157

156158
await t.assert.rejects(
157-
fetch(`http://localhost:${server.address().port}`, {
159+
fetch(`http://127.0.0.1:${server.address().port}`, {
158160
dispatcher: client,
159161
keepalive: false,
160162
headers: { 'x-encoding-count': '100' }

test/headers-as-array.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ test('handle headers as array', async (t) => {
2020
const client = new Client(`http://localhost:${server.address().port}`)
2121
after(() => client.close())
2222

23+
client.on('disconnect', () => {
24+
if (!client.closed && !client.destroyed) {
25+
t.fail('unexpected disconnect')
26+
}
27+
})
28+
2329
client.request({
2430
path: '/',
2531
method: 'GET',
@@ -46,6 +52,12 @@ test('handle multi-valued headers as array', async (t) => {
4652
const client = new Client(`http://localhost:${server.address().port}`)
4753
after(() => client.close())
4854

55+
client.on('disconnect', () => {
56+
if (!client.closed && !client.destroyed) {
57+
t.fail('unexpected disconnect')
58+
}
59+
})
60+
4961
client.request({
5062
path: '/',
5163
method: 'GET',
@@ -72,6 +84,12 @@ test('handle headers with array', async (t) => {
7284
const client = new Client(`http://localhost:${server.address().port}`)
7385
after(() => client.close())
7486

87+
client.on('disconnect', () => {
88+
if (!client.closed && !client.destroyed) {
89+
t.fail('unexpected disconnect')
90+
}
91+
})
92+
7593
client.request({
7694
path: '/',
7795
method: 'GET',
@@ -98,6 +116,12 @@ test('handle multi-valued headers', async (t) => {
98116
const client = new Client(`http://localhost:${server.address().port}`)
99117
after(() => client.close())
100118

119+
client.on('disconnect', () => {
120+
if (!client.closed && !client.destroyed) {
121+
t.fail('unexpected disconnect')
122+
}
123+
})
124+
101125
client.request({
102126
path: '/',
103127
method: 'GET',
@@ -118,6 +142,12 @@ test('fail if headers array is odd', async (t) => {
118142
const client = new Client(`http://localhost:${server.address().port}`)
119143
after(() => client.close())
120144

145+
client.on('disconnect', () => {
146+
if (!client.closed && !client.destroyed) {
147+
t.fail('unexpected disconnect')
148+
}
149+
})
150+
121151
client.request({
122152
path: '/',
123153
method: 'GET',
@@ -141,6 +171,12 @@ test('fail if headers is not an object or an array', async (t) => {
141171
const client = new Client(`http://localhost:${server.address().port}`)
142172
after(() => client.close())
143173

174+
client.on('disconnect', () => {
175+
if (!client.closed && !client.destroyed) {
176+
t.fail('unexpected disconnect')
177+
}
178+
})
179+
144180
client.request({
145181
path: '/',
146182
method: 'GET',

test/headers-crlf.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ test('CRLF Injection in Nodejs ‘undici’ via host', async (t) => {
2121
const client = new Client(`http://localhost:${server.address().port}`)
2222
after(() => client.close())
2323

24+
client.on('disconnect', () => {
25+
if (!client.closed && !client.destroyed) {
26+
t.fail('unexpected disconnect')
27+
}
28+
})
29+
2430
const unsanitizedContentTypeInput = '12 \r\n\r\naaa:aaa'
2531

2632
try {

test/http-100.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ test('ignore informational response', async (t) => {
2121
const client = new Client(`http://localhost:${server.address().port}`)
2222
after(() => client.close())
2323

24+
client.on('disconnect', () => {
25+
if (!client.closed && !client.destroyed) {
26+
t.fail('unexpected disconnect')
27+
}
28+
})
29+
2430
client.request({
2531
path: '/',
2632
method: 'POST',
@@ -137,6 +143,12 @@ test('1xx response without timeouts', async t => {
137143
})
138144
after(() => client.close())
139145

146+
client.on('disconnect', () => {
147+
if (!client.closed && !client.destroyed) {
148+
t.fail('unexpected disconnect')
149+
}
150+
})
151+
140152
client.request({
141153
path: '/',
142154
method: 'POST',

test/https.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ test('https get with tls opts', async (t) => {
2525
})
2626
after(() => client.close())
2727

28+
client.on('disconnect', () => {
29+
if (!client.closed && !client.destroyed) {
30+
t.fail('unexpected disconnect')
31+
}
32+
})
33+
2834
client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => {
2935
t.ifError(err)
3036
t.strictEqual(statusCode, 200)
@@ -61,6 +67,12 @@ test('https get with tls opts ip', async (t) => {
6167
})
6268
after(() => client.close())
6369

70+
client.on('disconnect', () => {
71+
if (!client.closed && !client.destroyed) {
72+
t.fail('unexpected disconnect')
73+
}
74+
})
75+
6476
client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => {
6577
t.ifError(err)
6678
t.strictEqual(statusCode, 200)

0 commit comments

Comments
 (0)