Skip to content

Commit a16fad8

Browse files
committed
Don't detect async handles already queued to close
Some types of async resources in Node.js are not destroyed until *after* their `close` or `end` or similar callbacks and events run, leading to a situation where the `--detectOpenHandles` option can falsely flag resources that have been cleaned up in user code and are already scheduled for destruction. For example, if a test ends from the callback to `TCPServer.close()` and no other tests or lifecycle functions impose additional delays, `--detectOpenHandles` will flag the server even though it has been closed. This is the main cause of issues people encounter with Supertest (see jestjs#8554). This addresses the issue by adding a short delay before collecting open handles. Depends on jestjs#11382.
1 parent 4803a6c commit a16fad8

File tree

6 files changed

+75
-17
lines changed

6 files changed

+75
-17
lines changed

Diff for: e2e/__tests__/__snapshots__/detectOpenHandles.ts.snap

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`does not print info about open handlers for a server that is already closed 1`] = ``;
4+
35
exports[`prints message about flag on forceExit 1`] = `Force exiting Jest: Have you considered using \`--detectOpenHandles\` to detect async operations that kept running after all tests finished?`;
46

57
exports[`prints message about flag on slow tests 1`] = `

Diff for: e2e/__tests__/detectOpenHandles.ts

+12
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,15 @@ it('prints out info about open handlers from lifecycle functions with a `done` c
155155

156156
expect(wrap(textAfterTest)).toMatchSnapshot();
157157
});
158+
159+
it('does not print info about open handlers for a server that is already closed', async () => {
160+
const run = runContinuous('detect-open-handles', [
161+
'recently-closed',
162+
'--detectOpenHandles',
163+
]);
164+
await run.waitUntil(({stderr}) => stderr.includes('Ran all test suites'));
165+
const {stderr} = await run.end();
166+
const textAfterTest = getTextAfterTest(stderr);
167+
168+
expect(wrap(textAfterTest)).toMatchSnapshot();
169+
});

Diff for: e2e/detect-open-handles/__tests__/recently-closed.js

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {createServer} from 'http';
9+
10+
test('a recently closed server should not be detected by --detectOpenHandles', done => {
11+
const server = createServer((_, response) => response.end('ok'));
12+
server.listen(0, () => {
13+
expect(true).toBe(true);
14+
15+
// Close server and return immediately on callback. During the "close"
16+
// callback, async hooks usually have not yet been called, but we want to
17+
// make sure Jest can figure out that this server is closed.
18+
server.close(done);
19+
});
20+
});

Diff for: packages/jest-core/src/__tests__/collectHandles.test.js

+26-12
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,25 @@ import {PerformanceObserver} from 'perf_hooks';
1111
import collectHandles from '../collectHandles';
1212

1313
describe('collectHandles', () => {
14-
it('should collect Timeout', () => {
14+
it('should collect Timeout', async () => {
1515
const handleCollector = collectHandles();
1616

1717
const interval = setInterval(() => {}, 100);
1818

19-
const openHandles = handleCollector();
19+
const openHandles = await handleCollector();
2020

2121
expect(openHandles).toHaveLength(1);
2222
expect(openHandles[0].message).toContain('Timeout');
2323

2424
clearInterval(interval);
2525
});
2626

27-
it('should not collect the PerformanceObserver open handle', () => {
27+
it('should not collect the PerformanceObserver open handle', async () => {
2828
const handleCollector = collectHandles();
2929
const obs = new PerformanceObserver((list, observer) => {});
3030
obs.observe({entryTypes: ['mark']});
3131

32-
const openHandles = handleCollector();
32+
const openHandles = await handleCollector();
3333

3434
expect(openHandles).toHaveLength(0);
3535
obs.disconnect();
@@ -40,14 +40,28 @@ describe('collectHandles', () => {
4040
const server = http.createServer((_, response) => response.end('ok'));
4141
server.listen(0, () => {
4242
// Collect results while server is still open.
43-
const openHandles = handleCollector();
44-
45-
server.close(() => {
46-
expect(openHandles).toContainEqual(
47-
expect.objectContaining({message: 'TCPSERVERWRAP'}),
48-
);
49-
done();
50-
});
43+
handleCollector()
44+
.then(openHandles => {
45+
server.close(() => {
46+
expect(openHandles).toContainEqual(
47+
expect.objectContaining({message: 'TCPSERVERWRAP'}),
48+
);
49+
done();
50+
});
51+
})
52+
.catch(done);
5153
});
5254
});
55+
56+
it('should not collect handles that have been queued to close', async () => {
57+
const handleCollector = collectHandles();
58+
const server = http.createServer((_, response) => response.end('ok'));
59+
60+
// Start and stop server.
61+
await new Promise(r => server.listen(0, r));
62+
await new Promise(r => server.close(r));
63+
64+
const openHandles = await handleCollector();
65+
expect(openHandles).toHaveLength(0);
66+
});
5367
});

Diff for: packages/jest-core/src/collectHandles.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
/* eslint-disable local/ban-types-eventually */
99

1010
import * as asyncHooks from 'async_hooks';
11+
import {promisify} from 'util';
1112
import stripAnsi = require('strip-ansi');
1213
import type {Config} from '@jest/types';
1314
import {formatExecError} from 'jest-message-util';
1415
import {ErrorWithStack} from 'jest-util';
1516

16-
export type HandleCollectionResult = () => Array<Error>;
17+
export type HandleCollectionResult = () => Promise<Array<Error>>;
1718

1819
function stackIsFromUser(stack: string) {
1920
// Either the test file, or something required by it
@@ -41,6 +42,8 @@ const alwaysActive = () => true;
4142

4243
const hasWeakRef = typeof WeakRef === 'function';
4344

45+
const nextTask = promisify(setImmediate);
46+
4447
// Inspired by https://github.com/mafintosh/why-is-node-running/blob/master/index.js
4548
// Extracted as we want to format the result ourselves
4649
export default function collectHandles(): HandleCollectionResult {
@@ -100,7 +103,14 @@ export default function collectHandles(): HandleCollectionResult {
100103

101104
hook.enable();
102105

103-
return () => {
106+
return async () => {
107+
// Wait until the next JS task for any async resources that have been queued
108+
// for destruction to actually be destroyed.
109+
// For example, Node.js TCP Servers are not destroyed until *after* their
110+
// `close` callback runs. If someone finishes a test from the `close`
111+
// callback, we will not yet have seen the resource be destroyed here.
112+
await nextTask();
113+
104114
hook.disable();
105115

106116
// Get errors for every async resource still referenced at this moment

Diff for: packages/jest-core/src/runJest.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ type ProcessResultOptions = Pick<
7575
outputStream: NodeJS.WriteStream;
7676
};
7777

78-
const processResults = (
78+
const processResults = async (
7979
runResults: AggregatedResult,
8080
options: ProcessResultOptions,
8181
) => {
@@ -89,7 +89,7 @@ const processResults = (
8989
} = options;
9090

9191
if (collectHandles) {
92-
runResults.openHandles = collectHandles();
92+
runResults.openHandles = await collectHandles();
9393
} else {
9494
runResults.openHandles = [];
9595
}
@@ -280,7 +280,7 @@ export default async function runJest({
280280
await runGlobalHook({allTests, globalConfig, moduleName: 'globalTeardown'});
281281
}
282282

283-
processResults(results, {
283+
await processResults(results, {
284284
collectHandles,
285285
json: globalConfig.json,
286286
onComplete,

0 commit comments

Comments
 (0)