Skip to content

Commit 3ff2a6b

Browse files
committed
test_runner: fix flaky retry defects from review
Fix eight defects in the flaky retry loop, each covered by a regression test added in the previous commit: - do not retry a flaky expectFailure test that unexpectedly passes - retry a flaky parent when a failure surfaces only through a subtest - publish the tracing channel end per attempt so spans stay balanced - inherit flaky only from a suite parent, not a test to its subtests - reset the MockTracker between retries - run a test-level after hook only after the final attempt - abort the previous attempt's signal so its work is torn down - publish a flaky expectFailure throw's error to the node.test channel The remaining intermediate-attempt subtest output inflation across retries is the documented known limitation and is unchanged. Signed-off-by: sangwook <rewq5991@gmail.com>
1 parent 0a99853 commit 3ff2a6b

3 files changed

Lines changed: 57 additions & 31 deletions

File tree

lib/internal/test_runner/test.js

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,10 @@ class Test extends AsyncResource {
744744
this.expectedAssertions = plan;
745745
this.cancelled = false;
746746
if (flaky === undefined || flaky === null) {
747-
this.flakyRetries = this.parent?.flakyRetries ?? 0;
747+
// Inherit flaky only from a suite parent: a suite propagates it to its
748+
// test-cases, but a test must not propagate it to its own subtests.
749+
this.flakyRetries = this.parent?.reportedType === 'suite' ?
750+
(this.parent.flakyRetries ?? 0) : 0;
748751
} else if (typeof flaky === 'boolean') {
749752
this.flakyRetries = flaky ? kDefaultFlakyRetries : 0;
750753
} else if (typeof flaky === 'number') {
@@ -1379,6 +1382,9 @@ class Test extends AsyncResource {
13791382
this.error = null;
13801383
this.passed = false;
13811384
this.cancelled = false;
1385+
// Restore any mocks applied by the previous attempt so the retry starts
1386+
// clean (the MockTracker is otherwise only reset in postRun).
1387+
this.mock?.reset();
13821388
ArrayPrototypeSplice(this.diagnostics, baseDiagnosticsCount);
13831389
// Reset assertion bookkeeping; re-arm the plan if one was requested via
13841390
// the `plan` option so that `t.plan(N)` does not accumulate across
@@ -1405,9 +1411,13 @@ class Test extends AsyncResource {
14051411
// is resolved by the snapshot manager; it is intentionally not reset from
14061412
// here because doing so would corrupt sibling tests in the same file. The
14071413
// final attempt's snapshots win (last-write-wins).
1408-
// A fresh abort controller per attempt prevents an abort from a previous
1409-
// attempt's timeout/cancel from being observed as an external abort by
1410-
// the retry decision below.
1414+
// Abort the previous attempt's controller so any signal-bound work it
1415+
// started (e.g. a timer awaiting t.signal) is torn down instead of
1416+
// leaking. The retry decision for that attempt has already been made, so
1417+
// this cannot be misread as an external abort. A fresh controller then
1418+
// backs the next attempt, preventing the previous abort from being
1419+
// observed as an external abort by the retry decision below.
1420+
this.abortController.abort();
14111421
this.abortController = new AbortController();
14121422
this.signal = this.abortController.signal;
14131423
afterEach = runOnce(async () => {
@@ -1493,14 +1503,6 @@ class Test extends AsyncResource {
14931503
await afterEach();
14941504
await after();
14951505
} catch (err) {
1496-
// Publish to the tracing channel's error stream only on the final
1497-
// attempt; intermediate retries of a flaky test are silent. For a
1498-
// non-flaky test (maxAttempts === 1) this always fires, as before.
1499-
const willRetry = attempt < maxAttempts &&
1500-
!(this.signal.aborted || this.outerSignal?.aborted);
1501-
if (!willRetry && channelContext !== null && testChannel.error.hasSubscribers) {
1502-
publishError(err);
1503-
}
15041506
if (isTestFailureError(err)) {
15051507
if (err.failureType === kTestTimeoutFailure && this.flakyRetries === 0) {
15061508
// Non-flaky timeouts are classified as cancelled, unchanged.
@@ -1516,23 +1518,49 @@ class Test extends AsyncResource {
15161518
} else {
15171519
this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure));
15181520
}
1521+
// The pass/fail outcome is now known (e.g. an expectFailure throw is a
1522+
// pass, so it is not retried). Publish to the tracing channel's error
1523+
// stream and run the test-level `after` hook only on the final attempt;
1524+
// intermediate retries of a flaky test are silent and keep `after` for
1525+
// the end. For a non-flaky test (maxAttempts === 1) the error always
1526+
// publishes, as before.
1527+
const willRetry = attempt < maxAttempts &&
1528+
!this.passed &&
1529+
!this.expectFailure &&
1530+
!(this.signal.aborted || this.outerSignal?.aborted);
1531+
if (!willRetry && channelContext !== null && testChannel.error.hasSubscribers) {
1532+
publishError(err);
1533+
}
15191534
try { await afterEach(); } catch { /* test is already failing, let's ignore the error */ }
1520-
try { await after(); } catch { /* Ignore error. */ }
1535+
if (!willRetry) {
1536+
try { await after(); } catch { /* Ignore error. */ }
1537+
}
15211538
} finally {
15221539
stopPromise?.[SymbolDispose]();
15231540
}
15241541

1542+
// Publish the tracing channel `end` once per attempt so each `start`
1543+
// (published when the body runs) is paired, keeping spans balanced even
1544+
// when a flaky test is retried.
1545+
if (channelContext !== null && testChannel.end.hasSubscribers) {
1546+
publishEnd();
1547+
}
1548+
15251549
// Decide whether to retry based on the resulting state, not on whether the
15261550
// attempt threw: a non-throwing failure via this.fail() must also be
1527-
// retried. An external abort (user signal, parent, or process cancellation)
1528-
// is intentional and stops retrying immediately. What remains -
1529-
// timeouts and ordinary failures - is retried.
1530-
if (this.passed) {
1551+
// retried, and a failure that surfaces only through a subtest (promoted to
1552+
// the parent later, in postRun) is detected here so the parent still
1553+
// retries. An external abort and an expectFailure verdict stop retrying
1554+
// immediately.
1555+
const subtestsFailed = this.flakyRetries > 0 &&
1556+
ArrayPrototypeSome(this.subtests, (subtest) => !subtest.passed && !subtest.isTodo);
1557+
if (this.passed && !subtestsFailed) {
15311558
break;
15321559
}
15331560
const shouldRetry =
15341561
attempt < maxAttempts &&
1535-
!this.passed &&
1562+
(!this.passed || subtestsFailed) &&
1563+
!this.expectFailure &&
15361564
!(this.signal.aborted || this.outerSignal?.aborted);
15371565
if (!shouldRetry) {
15381566
break;
@@ -1546,11 +1574,6 @@ class Test extends AsyncResource {
15461574
this.abortController.abort();
15471575
}
15481576

1549-
// Publish diagnostics_channel end event if the channel has subscribers (in both success and error cases)
1550-
if (channelContext !== null && testChannel.end.hasSubscribers) {
1551-
publishEnd();
1552-
}
1553-
15541577
if (this.parent !== null || typeof this.hookType === 'string') {
15551578
// Clean up the test. Then, try to report the results and execute any
15561579
// tests that were pending due to available concurrency.

test/fixtures/test-runner/flaky/parent-subtest-retry.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
'use strict';
22
// A flaky parent whose only failure comes from a SUBTEST must be retried: the
33
// child fails on attempts 1-2 and passes on attempt 3, so the parent must keep
4-
// retrying and ultimately pass. The state file records the attempt count.
4+
// retrying and ultimately pass with retryCount === 2.
55
const { test } = require('node:test');
66
const assert = require('node:assert');
7-
const { writeFileSync } = require('node:fs');
87

9-
const stateFile = process.env.FLAKY_STATE;
108
let attempt = 0;
119

1210
test('parent retries when subtest fails', { flaky: 3 }, async (t) => {
1311
attempt++;
14-
writeFileSync(stateFile, String(attempt));
1512
await t.test('child', () => {
1613
if (attempt < 3) {
1714
assert.fail(`child fails on attempt ${attempt}`);

test/parallel/test-runner-flaky.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,10 +378,16 @@ test('flaky: expectFailure that unexpectedly passes is not retried', async () =>
378378
});
379379

380380
test('flaky: parent retries when only a subtest fails', async () => {
381-
const { code, state } = await spawnFlaky(
382-
fixtures.path('test-runner/flaky/parent-subtest-retry.js'), 'parent-subtest.txt');
383-
assert.strictEqual(state, '3', `parent body ran ${state}x; expected 3 (retry until child passes)`);
384-
assert.strictEqual(code, 0, `parent must ultimately pass, got exit ${code}`);
381+
// The parent body never throws; the failure surfaces only through the child,
382+
// which is promoted to the parent in postRun. The fix detects that before the
383+
// retry decision, so the parent retries until the child passes (attempt 3) and
384+
// its pass carries retryCount === 2. (Leaked intermediate-attempt children are
385+
// the separate, documented subtest-output limitation, so this asserts only the
386+
// parent's retry outcome via the run() event stream.)
387+
const events = await collectEvents(fixtures.path('test-runner/flaky/parent-subtest-retry.js'));
388+
const passes = byName(events.pass, 'parent retries when subtest fails');
389+
assert.strictEqual(passes.length, 1, 'the flaky parent must ultimately pass');
390+
assert.strictEqual(passes[0].retryCount, 2, 'parent retried until the subtest passed on attempt 3');
385391
});
386392

387393
test('flaky: tracing channel start/end stay balanced across retries', async () => {

0 commit comments

Comments
 (0)