Skip to content

Commit 3e3b213

Browse files
committed
Fail async tests if not ended before event loop empties
Rather than keeping an infinite timer open, waiting for `t.end()` to be called, fail the callback test if it is not ended when the event loop empties. Similarly, fail promise/observable returning tests if the promise hasn't fulfilled or the observable hasn't completed when the event loop empties. Note that user code can keep the event loop busy, e.g. if it's listening on a socket or starts long timers. Even after this change, async tests may hang if the event loop is kept busy.
1 parent 7aca025 commit 3e3b213

File tree

11 files changed

+106
-23
lines changed

11 files changed

+106
-23
lines changed

lib/main.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ function test(props) {
4949
}
5050

5151
function exit() {
52-
const stats = runner._buildStats();
52+
// Reference the IPC channel now that tests have finished running.
53+
adapter.ipcChannel.ref();
5354

55+
const stats = runner._buildStats();
5456
adapter.send('results', {stats});
5557
}
5658

@@ -71,6 +73,11 @@ globals.setImmediate(() => {
7173
runner.on('test', test);
7274

7375
process.on('ava-run', options => {
76+
// Unreference the IPC channel. This stops it from keeping the event loop
77+
// busy, which means the `beforeExit` event can be used to detect when tests
78+
// stall.
79+
adapter.ipcChannel.unref();
80+
7481
runner.run(options)
7582
.then(exit)
7683
.catch(err => {

lib/process-adapter.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ exports.send = (name, data) => {
2222
});
2323
};
2424

25+
// `process.channel` was added in Node.js 7.1.0, but the channel was available
26+
// through an undocumented API as `process._channel`.
27+
exports.ipcChannel = process.channel || process._channel;
28+
2529
const opts = JSON.parse(process.argv[2]);
2630
exports.opts = opts;
2731

lib/sequence.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,26 @@ class Sequence {
1313
run() {
1414
const iterator = this.runnables[Symbol.iterator]();
1515

16+
let activeRunnable;
17+
const onBeforeExit = () => {
18+
if (activeRunnable.finishDueToInactivity) {
19+
activeRunnable.finishDueToInactivity();
20+
}
21+
};
22+
process.on('beforeExit', onBeforeExit);
23+
1624
let allPassed = true;
25+
const finish = () => {
26+
process.removeListener('beforeExit', onBeforeExit);
27+
return allPassed;
28+
};
29+
1730
const runNext = () => {
1831
let promise;
1932

2033
for (let next = iterator.next(); !next.done; next = iterator.next()) {
21-
const passedOrPromise = next.value.run();
34+
activeRunnable = next.value;
35+
const passedOrPromise = activeRunnable.run();
2236
if (!passedOrPromise) {
2337
allPassed = false;
2438

@@ -33,7 +47,7 @@ class Sequence {
3347
}
3448

3549
if (!promise) {
36-
return allPassed;
50+
return finish();
3751
}
3852

3953
return promise.then(passed => {
@@ -42,7 +56,7 @@ class Sequence {
4256

4357
if (this.bail) {
4458
// Stop if the test failed and bail mode is on.
45-
return false;
59+
return finish();
4660
}
4761
}
4862

lib/test-worker.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ process.on('uncaughtException', exception => {
6969
stack: err.stack
7070
};
7171
}
72+
73+
// Ensure the IPC channel is refereced. The uncaught exception will kick off
74+
// the teardown sequence, for which the messages must be received.
75+
adapter.ipcChannel.ref();
76+
7277
adapter.send('uncaughtException', {exception: serialized});
7378
});
7479

lib/test.js

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use strict';
22
const isGeneratorFn = require('is-generator-fn');
3-
const maxTimeout = require('max-timeout');
43
const co = require('co-with-promise');
54
const observableToPromise = require('observable-to-promise');
65
const isPromise = require('is-promise');
@@ -102,6 +101,7 @@ class Test {
102101
this.calledEnd = false;
103102
this.duration = null;
104103
this.endCallbackFinisher = null;
104+
this.finishDueToInactivity = null;
105105
this.finishing = false;
106106
this.pendingAssertions = [];
107107
this.planCount = null;
@@ -221,9 +221,6 @@ class Test {
221221
run() {
222222
this.startedAt = globals.now();
223223

224-
// Wait until all assertions are complete
225-
this.timeoutHandle = globals.setTimeout(() => {}, maxTimeout);
226-
227224
const result = this.callFn();
228225
if (!result.ok) {
229226
throwsHelper(result.error);
@@ -262,23 +259,36 @@ class Test {
262259
this.endCallbackFinisher = () => {
263260
resolve(this.finishPromised());
264261
};
262+
263+
this.finishDueToInactivity = () => {
264+
this.saveFirstError(new Error('`t.end()` was never called'));
265+
resolve(this.finishPromised());
266+
};
265267
});
266268
}
267269

268270
if (promise) {
269-
return promise.then(
270-
() => this.finish(),
271-
err => {
272-
throwsHelper(err);
273-
274-
this.saveFirstError(new assert.AssertionError({
275-
message: 'Rejected promise returned by test',
276-
stack: err instanceof Error && err.stack,
277-
values: [formatAssertError.formatWithLabel('Rejection reason:', err)]
278-
}));
279-
return this.finish();
280-
}
281-
);
271+
return new Promise(resolve => {
272+
this.finishDueToInactivity = () => {
273+
const err = returnedObservable ?
274+
new Error('Observable returned by test never completed') :
275+
new Error('Promise returned by test never resolved');
276+
this.saveFirstError(err);
277+
resolve(this.finishPromised());
278+
};
279+
280+
promise
281+
.catch(err => {
282+
throwsHelper(err);
283+
284+
this.saveFirstError(new assert.AssertionError({
285+
message: 'Rejected promise returned by test',
286+
stack: err instanceof Error && err.stack,
287+
values: [formatAssertError.formatWithLabel('Rejection reason:', err)]
288+
}));
289+
})
290+
.then(() => resolve(this.finishPromised()));
291+
});
282292
}
283293

284294
return this.finish();
@@ -314,7 +324,6 @@ class Test {
314324

315325
completeFinish() {
316326
this.duration = globals.now() - this.startedAt;
317-
globals.clearTimeout(this.timeoutHandle);
318327

319328
let reason = this.assertError;
320329
let passed = !reason;

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@
142142
"lodash.isequal": "^4.5.0",
143143
"loud-rejection": "^1.2.0",
144144
"matcher": "^0.1.1",
145-
"max-timeout": "^1.0.0",
146145
"md5-hex": "^2.0.0",
147146
"meow": "^3.7.0",
148147
"mkdirp": "^0.5.1",

profile.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ babelConfigHelper.build(process.cwd(), cacheDir, conf.babel, true)
101101
let uncaughtExceptionCount = 0;
102102

103103
// Mock the behavior of a parent process
104+
process.channel = {ref() {}, unref() {}};
104105
process.send = data => {
105106
if (data && data.ava) {
106107
const name = data.name.replace(/^ava-/, '');

test/cli.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,24 @@ test('tests without assertions do not fail if failWithoutAssertions option is se
425425
t.end();
426426
});
427427
});
428+
429+
test('callback tests fail if event loop empties before they\'re ended', t => {
430+
execCli('callback.js', {dirname: 'fixture/stalled-tests'}, (_, __, stderr) => {
431+
t.match(stderr, /`t\.end\(\)` was never called/);
432+
t.end();
433+
});
434+
});
435+
436+
test('observable tests fail if event loop empties before they\'re resolved', t => {
437+
execCli('observable.js', {dirname: 'fixture/stalled-tests'}, (_, __, stderr) => {
438+
t.match(stderr, /Observable returned by test never completed/);
439+
t.end();
440+
});
441+
});
442+
443+
test('promise tests fail if event loop empties before they\'re resolved', t => {
444+
execCli('promise.js', {dirname: 'fixture/stalled-tests'}, (_, __, stderr) => {
445+
t.match(stderr, /Promise returned by test never resolved/);
446+
t.end();
447+
});
448+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
'use strict';
2+
3+
const test = require('../../..');
4+
test.cb(t => {
5+
t.pass();
6+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use strict';
2+
const Observable = require('zen-observable')
3+
4+
const test = require('../../..');
5+
test(t => {
6+
return new Observable(() => {
7+
t.pass();
8+
});
9+
});

test/fixture/stalled-tests/promise.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use strict';
2+
3+
const test = require('../../..');
4+
test(t => {
5+
return new Promise(() => {
6+
t.pass();
7+
});
8+
});

0 commit comments

Comments
 (0)