Skip to content

Commit 3b253f8

Browse files
authored
Wait for closed resources to actually close before detecting open handles (#11429)
1 parent 27bee72 commit 3b253f8

File tree

8 files changed

+128
-24
lines changed

8 files changed

+128
-24
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
- `[jest-core]` Use `WeakRef` to hold timers when detecting open handles ([#11277](https://github.com/facebook/jest/pull/11277))
7272
- `[jest-core]` Correctly detect open handles that were created in test functions using `done` callbacks ([#11382](https://github.com/facebook/jest/pull/11382))
7373
- `[jest-core]` Do not collect `RANDOMBYTESREQUEST` as open handles ([#11278](https://github.com/facebook/jest/pull/11278))
74+
- `[jest-core]` Wait briefly for open handles to close before flagging them when using `--detectOpenHandles` ([#11429](https://github.com/facebook/jest/pull/11429))
7475
- `[jest-diff]` [**BREAKING**] Use only named exports ([#11371](https://github.com/facebook/jest/pull/11371))
7576
- `[jest-each]` [**BREAKING**] Ignore excess words in headings ([#8766](https://github.com/facebook/jest/pull/8766))
7677
- `[jest-each]` Support array index with template strings ([#10763](https://github.com/facebook/jest/pull/10763))

e2e/__tests__/__snapshots__/detectOpenHandles.ts.snap

+3-1
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`] = `
@@ -11,7 +13,7 @@ This usually means that there are asynchronous operations that weren't stopped i
1113
exports[`prints out info about open handlers 1`] = `
1214
Jest has detected the following 1 open handle potentially keeping Jest from exiting:
1315
14-
GETADDRINFOREQWRAP
16+
DNSCHANNEL
1517
1618
12 | const app = new Server();
1719
13 |

e2e/__tests__/detectOpenHandles.ts

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

167167
expect(wrap(textAfterTest)).toMatchSnapshot();
168168
});
169+
170+
it('does not print info about open handlers for a server that is already closed', async () => {
171+
const run = runContinuous('detect-open-handles', [
172+
'recently-closed',
173+
'--detectOpenHandles',
174+
]);
175+
await run.waitUntil(({stderr}) => stderr.includes('Ran all test suites'));
176+
const {stderr} = await run.end();
177+
const textAfterTest = getTextAfterTest(stderr);
178+
179+
expect(wrap(textAfterTest)).toMatchSnapshot();
180+
});
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+
});

packages/jest-core/src/__tests__/collectHandles.test.js

+53-15
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,30 @@ 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

21-
expect(openHandles).toHaveLength(1);
22-
expect(openHandles[0].message).toContain('Timeout');
21+
expect(openHandles).toContainEqual(
22+
expect.objectContaining({message: 'Timeout'}),
23+
);
2324

2425
clearInterval(interval);
2526
});
2627

27-
it('should not collect the PerformanceObserver open handle', () => {
28+
it('should not collect the PerformanceObserver open handle', async () => {
2829
const handleCollector = collectHandles();
2930
const obs = new PerformanceObserver((list, observer) => {});
3031
obs.observe({entryTypes: ['mark']});
3132

32-
const openHandles = handleCollector();
33+
const openHandles = await handleCollector();
3334

34-
expect(openHandles).toHaveLength(0);
35+
expect(openHandles).not.toContainEqual(
36+
expect.objectContaining({message: 'PerformanceObserver'}),
37+
);
3538
obs.disconnect();
3639
});
3740

@@ -40,14 +43,49 @@ describe('collectHandles', () => {
4043
const server = http.createServer((_, response) => response.end('ok'));
4144
server.listen(0, () => {
4245
// 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-
});
46+
handleCollector()
47+
.then(openHandles => {
48+
server.close(() => {
49+
expect(openHandles).toContainEqual(
50+
expect.objectContaining({message: 'TCPSERVERWRAP'}),
51+
);
52+
done();
53+
});
54+
})
55+
.catch(done);
5156
});
5257
});
58+
59+
it('should not collect handles that have been queued to close', async () => {
60+
const handleCollector = collectHandles();
61+
const server = http.createServer((_, response) => response.end('ok'));
62+
63+
// Start and stop server.
64+
await new Promise(r => server.listen(0, r));
65+
await new Promise(r => server.close(r));
66+
67+
const openHandles = await handleCollector();
68+
expect(openHandles).toHaveLength(0);
69+
});
70+
71+
it('should collect handles indirectly triggered by user code', async () => {
72+
const handleCollector = collectHandles();
73+
74+
// Calling `server.listen` with just a port (e.g. `server.listen(0)`)
75+
// creates a `TCPSERVERWRAP` async resource. However, including a `host`
76+
// option instead creates a `GETADDRINFOREQWRAP` resource that only
77+
// lasts for the lifetime of the `listen()` call, but which *indirectly*
78+
// creates a long-lived `TCPSERVERWRAP` resource. We want to make sure we
79+
// capture that long-lived resource.
80+
const server = new http.Server();
81+
await new Promise(r => server.listen({host: 'localhost', port: 0}, r));
82+
83+
const openHandles = await handleCollector();
84+
85+
await new Promise(r => server.close(r));
86+
87+
expect(openHandles).toContainEqual(
88+
expect.objectContaining({message: 'TCPSERVERWRAP'}),
89+
);
90+
});
5391
});

packages/jest-core/src/collectHandles.ts

+27-5
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
@@ -42,6 +43,8 @@ const alwaysActive = () => true;
4243
// @ts-expect-error: doesn't exist in v10 typings
4344
const hasWeakRef = typeof WeakRef === 'function';
4445

46+
const asyncSleep = promisify(setTimeout);
47+
4548
// Inspired by https://github.com/mafintosh/why-is-node-running/blob/master/index.js
4649
// Extracted as we want to format the result ourselves
4750
export default function collectHandles(): HandleCollectionResult {
@@ -56,7 +59,7 @@ export default function collectHandles(): HandleCollectionResult {
5659
init: function initHook(
5760
asyncId,
5861
type,
59-
_triggerAsyncId,
62+
triggerAsyncId,
6063
resource: {} | NodeJS.Timeout,
6164
) {
6265
if (
@@ -68,9 +71,21 @@ export default function collectHandles(): HandleCollectionResult {
6871
) {
6972
return;
7073
}
71-
const error = new ErrorWithStack(type, initHook);
74+
const error = new ErrorWithStack(type, initHook, 100);
75+
let fromUser = stackIsFromUser(error.stack || '');
76+
77+
// If the async resource was not directly created by user code, but was
78+
// triggered by another async resource from user code, track it and use
79+
// the original triggering resource's stack.
80+
if (!fromUser) {
81+
const triggeringHandle = activeHandles.get(triggerAsyncId);
82+
if (triggeringHandle) {
83+
fromUser = true;
84+
error.stack = triggeringHandle.error.stack;
85+
}
86+
}
7287

73-
if (stackIsFromUser(error.stack || '')) {
88+
if (fromUser) {
7489
let isActive: () => boolean;
7590

7691
if (type === 'Timeout' || type === 'Immediate') {
@@ -102,7 +117,14 @@ export default function collectHandles(): HandleCollectionResult {
102117

103118
hook.enable();
104119

105-
return () => {
120+
return async () => {
121+
// Wait briefly for any async resources that have been queued for
122+
// destruction to actually be destroyed.
123+
// For example, Node.js TCP Servers are not destroyed until *after* their
124+
// `close` callback runs. If someone finishes a test from the `close`
125+
// callback, we will not yet have seen the resource be destroyed here.
126+
await asyncSleep(100);
127+
106128
hook.disable();
107129

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

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
}
@@ -282,7 +282,7 @@ export default async function runJest({
282282
await runGlobalHook({allTests, globalConfig, moduleName: 'globalTeardown'});
283283
}
284284

285-
processResults(results, {
285+
await processResults(results, {
286286
collectHandles,
287287
json: globalConfig.json,
288288
onComplete,

packages/jest-util/src/ErrorWithStack.ts

+9
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,19 @@ export default class ErrorWithStack extends Error {
99
constructor(
1010
message: string | undefined,
1111
callsite: (...args: Array<any>) => unknown,
12+
stackLimit?: number,
1213
) {
14+
// Ensure we have a large stack length so we get full details.
15+
const originalStackLimit = Error.stackTraceLimit;
16+
if (stackLimit) {
17+
Error.stackTraceLimit = Math.max(stackLimit, originalStackLimit || 10);
18+
}
19+
1320
super(message);
1421
if (Error.captureStackTrace) {
1522
Error.captureStackTrace(this, callsite);
1623
}
24+
25+
Error.stackTraceLimit = originalStackLimit;
1726
}
1827
}

0 commit comments

Comments
 (0)