Skip to content

Commit 20a2ab1

Browse files
SergeAstapovKrinkle
authored andcommitted
Core: Fix memory leak via config.timeoutHandler from last async test
The timeout itself is naturally reached or cleared from a functional perspective, but the closure behind the timeout is still stored in `config.timeoutHandler` until another async test replaces it. This leaked one Test object, the last one, which made debugging memory leaks itself particulariy difficult. Closes #1708.
1 parent f503f06 commit 20a2ab1

4 files changed

Lines changed: 109 additions & 0 deletions

File tree

src/test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,13 @@ Test.prototype = {
341341
finish: function () {
342342
config.current = this;
343343

344+
// Release the timeout and timeout callback references to be garbage collected.
345+
// https://github.com/qunitjs/qunit/pull/1708
346+
if (setTimeout) {
347+
clearTimeout(this.timeout);
348+
config.timeoutHandler = null;
349+
}
350+
344351
// Release the test callback to ensure that anything referenced has been
345352
// released to be garbage collected.
346353
this.callback = undefined;

test/cli/cli-main.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,12 @@ HOOK: BCD1 @ B after`;
313313
const execution = await execute(command);
314314
assert.equal(execution.snapshot, getExpected(command));
315315
});
316+
317+
QUnit.test('memory-leak/test-object', async assert => {
318+
const command = ['node', '--expose-gc', '../../../bin/qunit.js', 'memory-leak/test-object.js'];
319+
const execution = await execute(command);
320+
assert.equal(execution.snapshot, getExpected(command));
321+
});
316322
}
317323

318324
// TODO: Workaround fact that child_process.spawn() args array is a lie on Windows.

test/cli/fixtures/expected/tap-outputs.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,15 @@ ok 2 module-closure check > memory release
465465
# pass 2
466466
# skip 0
467467
# todo 0
468+
# fail 0`,
469+
470+
"node --expose-gc ../../../bin/qunit.js memory-leak/test-object.js":
471+
`TAP version 13
472+
ok 1 test-object > example test
473+
1..1
474+
# pass 1
475+
# skip 0
476+
# todo 0
468477
# fail 0`,
469478

470479
'qunit only/test.js':
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/* globals gc */
2+
3+
const v8 = require('v8');
4+
5+
// Hold explicit references as well so that V8 will consistently
6+
// not be able to GC them until we ask it to. This allows us to
7+
// verify that our heap logic works correctly by asserting both
8+
// presence and absence.
9+
const foos = new Set();
10+
class Foo {
11+
constructor () {
12+
this.id = `FooNum${foos.size}`;
13+
foos.add(this);
14+
}
15+
16+
getId () {
17+
return this.id.slice(0, 3);
18+
}
19+
}
20+
21+
function streamToString (stream) {
22+
const chunks = [];
23+
return new Promise((resolve, reject) => {
24+
stream.on('data', chunk => chunks.push(chunk));
25+
stream.on('error', reject);
26+
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
27+
});
28+
}
29+
30+
// Regression test for https://github.com/qunitjs/qunit/pull/1708
31+
//
32+
// Unlike the module-closure.js case, this one can't use a second QUnit.test
33+
// to check the memory, because this one isn't about whether the memory is
34+
// released soon/at all, but about whether it is released specifically for
35+
// even the last test that executes. As soon as another async test begins,
36+
// the underlying root cause (config.timeoutHandler) is released when it is
37+
// replaced by the next test's timeout handler.
38+
//
39+
// The v8.getHeapSnapshot() function is async, so we can't use a synchronous
40+
// test either. Instead, use the QUnit.done() hook and rely on global errors
41+
// to communicate a failure.
42+
QUnit.done(async function () {
43+
// The snapshot is expected to contain entries like this:
44+
// > "FooNum<integer>"
45+
// It is important that the regex uses \d and that the above
46+
// comment doesn't include a number, as otherwise we will also
47+
// get matches for the memory of this function's source code.
48+
const reHeap = /^.*FooNum\d.*$/gm;
49+
50+
let snapshot = await streamToString(v8.getHeapSnapshot());
51+
let matches = snapshot.match(reHeap) || [];
52+
if (matches.length === 0) {
53+
QUnit.onUncaughtException(new Error('Heap before GC must contain matches'));
54+
return;
55+
}
56+
if (foos.size !== 1) {
57+
QUnit.onUncaughtException(new Error(`Registry must contain 1 Foo, but found ${foos.size}`));
58+
}
59+
60+
snapshot = matches = null;
61+
62+
// Comment out the below to test the failure mode
63+
foos.clear();
64+
65+
// Requires `--expose-gc` flag to function properly.
66+
gc();
67+
68+
snapshot = await streamToString(v8.getHeapSnapshot());
69+
matches = snapshot.match(reHeap);
70+
if (matches !== null) {
71+
QUnit.onUncaughtException(new Error(`Heap after GC must have no Foo left, but found ${matches.join(', ')}`));
72+
}
73+
});
74+
75+
QUnit.module('test-object', function () {
76+
QUnit.test('example test', function (assert) {
77+
// assert.async() calls test.internalStop(), which if timeout is non-zero,
78+
// will set global QUnit.config.timeoutHandler, which holds a reference
79+
// to the last internal Test object. While the timeout is cancelled
80+
// after the test finishes, the handler property used to be kept.
81+
assert.timeout(1000);
82+
const done = assert.async();
83+
this.foo1 = new Foo();
84+
assert.equal(this.foo1.getId(), 'Foo');
85+
setTimeout(done);
86+
});
87+
});

0 commit comments

Comments
 (0)