Skip to content

node:test: cancel subtests still pending when their parent finishes - #39286

Open
robobun wants to merge 5 commits into
mainfrom
farm/5a3bd761/node-test-cancel-pending-subtests
Open

node:test: cancel subtests still pending when their parent finishes#39286
robobun wants to merge 5 commits into
mainfrom
farm/5a3bd761/node-test-cancel-pending-subtests

Conversation

@robobun

@robobun robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • An inline subtest whose await was forgotten is always waited for by bun test, so this file reports 2 pass, 0 fail and exits 0; node --test (v26.3.0) reports slow subtest as 'test did not finish before its parent and was cancelled' (failureType: 'cancelledByParent'), fails the parent with 1 subtest failed, and exits 1:
    import { test } from "node:test";
    test("parent", async t => {
      await t.test("first", async () => {});
      t.test("slow subtest", async () => { await new Promise(r => setTimeout(r, 50)); }); // missing await
    });
  • Cause: executeTestNode() in src/js/node/test.ts ran drainSubtestChain(node) after the body, i.e. it waited for every subtest ever scheduled. Node's Test#run waits on subtestsPromise, which is created with the first subtest and resolved the first time no subtest is left unfinished, and is never re-armed; postRun() then cancels whatever is still unfinished. So the earlier awaited t.test() is what arms the check: a sync parent, or an async parent whose only unawaited subtest is the first one, is waited for in Node too (verified, and covered by the PASS cases in the fixture).

Fix

  • TestNode tracks unfinishedSubtests and subtestsSettled (Node's unfinishedSubtests / subtestsPromise). Every inline subtest (test, inline suite, and skip directive, which Node also counts toward the first settle) is scheduled through one chainSubtest() that maintains them. After the body (and after a plan({ wait }) wait) the test awaits subtestsSettled instead of draining the chain.
  • settleSubtests() is the subtest half of Node's postRun(), and runs where Node runs it, after the test's own afterEach/after hooks: whatever is still unfinished is cancelled and the chain is drained only so the cancellations get recorded. cancelUnfinishedSubtests() marks the subtree; a subtest that has not reached its turn on the chain reports the cancellation instead of running (finishCancelled(), no hooks or body), a running body has its stop promise rejected so the parent stops waiting for it (Node aborts the subtest's signal, which rejects its stopPromise), and a cancelled inline suite skips its hooks, drains its already cancelled children, and reports cancelledByParent itself, as in Node. The stop controller therefore exists for every run, not only when a timeout is set.
  • A hook or describe() callback that never settles cannot be cancelled, and neither can a subtest that is only waiting on its own after hooks (its result is already in and is kept). The drain is therefore raced against a second stop controller armed with whatever the body phase left of the test's timeout (the hooks in between stay exempt), which is the bound the drain had before when it ran inside the body race: such a test still fails with its own test timed out after Nms, within one timeout, instead of waiting for bun:test's watchdog (fixtures/31-timeout-bounds-hanging-hook.js). That timeout error now carries failureType: 'testTimeoutFailure' like Node's; the hook timeout does not, because Node reports a hook timeout as a hook failure.
  • Node starts a subtest's body synchronously inside t.test(), so a forgotten await on a subtest that finishes on microtasks alone (a sync body) is already complete when Node's postRun() runs; bun's chain starts it a tick later. settleSubtests() yields one setImmediate before cancelling when something is still pending, so those complete here too, while anything waiting on a timer or I/O is cancelled like in Node. Known remaining difference, on the lenient side only: a straggler that needs exactly one setImmediate (or a handful of microtasks more than Node's own continuation) completes here and is cancelled by Node; nothing Node passes is cancelled here.
  • Rolling failed subtests into the parent now also happens in the postRun position, so like in Node, a parent's after hooks see t.passed === true when only a subtest failed (previously false), and an expectFailure parent is no longer satisfied by a failing subtest alone.
  • run(): a child's cancelledByParent or testTimeoutFailure result is counted under counts.cancelled (Node's runner keys this off the failure type as well, kCanceledTests; testAborted is left out because t.signal never aborts in this shim), and the per-file summary treats cancellations as failures. Suites report through the same reportNodeToRunParent() as tests, so a cancelled suite carries the cancellation error.
  • Verified with test/js/node/test_runner/fixtures/30-cancelled-subtests.js (4 cancellation cases that must fail, 8 waiting cases that must pass) via two new tests in test/js/node/test_runner/node-test.test.ts: bun test counts/markers, and the run() event stream and counts. node --test v26.3.0 fails and passes exactly the same tests of the fixture and reports the same eight cancellations in the same order. Two more tests run fixtures/31-timeout-bounds-hanging-hook.js under bun test (every test ends with its own timeout error) and under run() (the timeouts land in counts.cancelled). Without the fix the cancellation cases of fixture 30 hang until the 5s bun:test timeout and its two tests fail; with it node-test.test.ts passes 49/49, and the ported test/js/node/test/parallel/test-runner-* files are unchanged (24 pass; test-runner-mock-timers-scheduler.js fails its 100ms wall-clock bound under the debug build with or without this change).
  • Related: node:test: report t.test() after parent finished as a parentAlreadyFinished failure #34583 handles the sibling shape where t.test() is called after the parent already finished; this PR is about subtests that exist but are unfinished when the parent finishes. The two touch different paths of addTest().
  • Overlap: node:test: abort t.signal and enforce the test-level signal option #39287 (opened at the same time, from a different report) implements the same postRun() sweep for the case where the parent is stopped by its timeout or signal, plus the actual aborting of t.signal, and also changes createStopController() and adds fixtures numbered 30 and 31, so the two conflict textually and one of them has to be rebased onto the other. The sweep here already covers the timeout trigger (a parent whose body race times out cancels its pending subtests the same way; fixture 31 exercises it), so if this lands first, node:test: abort t.signal and enforce the test-level signal option #39287 reduces to owning the AbortController on TestNode, aborting it from cancelUnfinishedSubtests() and at the end of executeTestNode(), and adding the signal option to the stop controller. If node:test: abort t.signal and enforce the test-level signal option #39287 lands first, the first-settle tracking, the grace hop, the bounded drain and the run() counting from here get rebased onto its cancel() / cancelSubtests() instead; either order works, and I will do the rebase of whichever one is second.

Background

  • node:test in bun is a shim over bun:test: top-level test() calls register bun:test tests; a t.test() inside a running test is an inline subtest, run by the shim itself. Inline subtests of one parent are serialized through subtestChain, a promise chain each subtest appends a link to; drainSubtestChain() waits until that chain goes idle. A parent's result under bun test is its own body outcome plus N subtests failed when any subtest failed.
  • executeTestNode() runs one test: inherited beforeEach hooks, the body raced against a stop controller (one promise that rejects on the test's timeout, now also when the parent cancels the test), the plan check, the verdict, afterEach/after hooks, and now the postRun step. Node's postRun() is the step after a test's hooks where it cancels unfinished subtests, counts failed ones, and reports.
  • run() is node:test's programmatic runner: it spawns bun test per file with NODE_TEST_CONTEXT set, the child prints one JSON event per finished test (reportNodeToRunParent()), and the parent rebuilds Node's event stream and counters from them (republishChildEvent()).
Node v26.3.0 probes behind the design
# The rule is "first settle", not "async parent":
sync parent, one unawaited 50ms subtest           -> node waits, pass
async parent, one unawaited 50ms subtest          -> node waits, pass
await t.test(first); unawaited 50ms subtest       -> cancelled, parent fails   (the report)

# What is cancelled at postRun (everything unfinished, recursively):
in-flight test, queued {skip} test, queued inline suite (+ child), empty suite, queued {todo} test
-> all reported cancelledByParent; parent "4 subtests failed" (todo excluded); suite's hooks and child never run

# Timing of postRun:
await t.test(first); unawaited sync body          -> pass
await t.test(first); unawaited body awaiting null -> pass
await t.test(first); unawaited body with 5 awaits -> cancelled
await t.test(first); unawaited setImmediate body  -> cancelled
30ms t.after() hook + unawaited 10ms subtest      -> pass (finishes during the hook)
parent body throws with an in-flight subtest      -> subtest cancelled; its after hook runs at once and sees passed=false, error=cancelledByParent
parent whose awaited subtest failed               -> t.after() sees passed=true, error=null; parent fails afterwards

The debug build with this change gives the same verdicts for all of the above except the two "cancelled" microtask/setImmediate lines, which pass (the lenient difference described under Fix).

A test used to drain its whole subtest chain after its body settled, so a
t.test() whose await was forgotten was always waited for and passed. Node
only waits until the first time every subtest created so far has finished
(subtestsPromise); a subtest started after that and still unfinished once
the test's own hooks have run fails with cancelledByParent and fails the
parent (postRun).

Track each node's unfinished subtests, wait for the first settle only, and
cancel what is left in the postRun position: a subtest that has not reached
its turn on the chain reports the cancellation instead of running, a running
body has its stop promise rejected so nobody waits for it, and an inline
suite cancels its own pending children. Because the chain starts a body a
tick later than Node's synchronous start, stragglers get until the next
macrotask before being cancelled, so a forgotten await on a subtest that
needs no timer or I/O still completes, as it does in Node. Subtest failures
are now rolled up after the after hooks, where Node does it, and a run()
parent counts cancelledByParent results under `cancelled`.
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a1c47979-c07f-4fdb-ace1-25ffbc09f7d8

📥 Commits

Reviewing files that changed from the base of the PR and between 8c5296a and e991e7e.

📒 Files selected for processing (5)
  • src/js/node/test.ts
  • test/js/node/test_runner/fixtures/30-cancelled-subtests.js
  • test/js/node/test_runner/fixtures/31-timeout-bounds-hanging-hook.js
  • test/js/node/test_runner/fixtures/run-events.js
  • test/js/node/test_runner/node-test.test.ts

Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review.


Walkthrough

Changes

The Node test runner now tracks cancellation separately from failure, shares timeout budgets across test execution and hooks, settles unfinished subtests, propagates suite results, and reports consistent run events. New fixtures and tests cover cancellation ordering, hanging hooks, timeout bounds, and event summaries.

Node test lifecycle

Layer / File(s) Summary
Cancellation result reporting
src/js/node/test.ts
Cancellation failures, suite events, child types, and file summary counts are now reported consistently.
Shared timeout execution
src/js/node/test.ts
Test bodies, plans, hooks, and subtests use a shared StopController with remaining-time tracking and cancellation handling.
Subtest and suite lifecycle
src/js/node/test.ts
Subtests and suites now await children, settle deferred work, propagate cancellation, and handle skipped and todo children.
Lifecycle and timeout regression coverage
test/js/node/test_runner/fixtures/30-cancelled-subtests.js, test/js/node/test_runner/fixtures/31-timeout-bounds-hanging-hook.js, test/js/node/test_runner/node-test.test.ts
Tests cover cancelled subtests, hanging hooks, timeout failure types, ordering, counts, and exit status.
Run event aggregation coverage
test/js/node/test_runner/fixtures/run-events.js, test/js/node/test_runner/node-test.test.ts
The test suite captures runner events and validates aggregated event and summary data.

Possibly related PRs

  • oven-sh/bun#39287: Both changes update Node-compatible test cancellation, timeout handling, signal propagation, and subtest lifecycle behavior.
🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The changes improve node:test cancellation but do not address #39's build, externalization, CommonJS, or Node require/loader objectives. Link the subtest-cancellation issue, or add changes that address #39's Node.js build-output blockers.
Out of Scope Changes check ⚠️ Warning The implementation and tests concern node:test subtest cancellation, which is unrelated to the linked issue's Node.js build-output requirements. Relink this PR to the issue that specifies node:test subtest cancellation, or limit the changes to #39's build-output requirements.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: cancelling unfinished inline subtests when their parent completes.
Description check ✅ Passed The description explains the problem, fix, background, verification, compatibility behavior, and related issues, although it does not use the template headings.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:14 PM PT - Aug 15th, 2026

@robobun, your commit e991e7e is building: #99168

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed, waiting on CI.

  • Reproduced with the snippet from the report: bun test (1.4.0 canary) reports 2 pass, 0 fail and exits 0; node --test v26.3.0 cancels slow subtest (test did not finish before its parent and was cancelled) and exits 1.
  • test/js/node/test_runner/fixtures/30-cancelled-subtests.js was run under node v26.3.0 as well: it fails the same 4 tests and passes the same 8, and run() reports the same eight cancellations in the same order as the new run() test expects. fixtures/31-timeout-bounds-hanging-hook.js covers the review findings (a stuck chain still ends on the test's own timeout, counted as cancelled by run()).
  • Without the fix the two fixture-30 tests in test/js/node/test_runner/node-test.test.ts fail (the cancellation cases hang until the bun:test timeout); with it the file passes 49/49, on a debug and on a release build.

Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Cancellation cannot interrupt a before hook or describe() callback that
never settles, so a subtest queued behind one kept the drain waiting until
bun:test's watchdog instead of failing with the test's own timeout, which
is what bounded the drain when it still ran inside the body race. Race the
drain against a stop controller armed with the same timeout. Also trims
the comments added by the previous commits.
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts
…ted arm

run() counted a timed out test as failed because the timeout error carried
no failure type; node reports it as testTimeoutFailure and counts it as
cancelled. The hook timeout is left untagged on purpose: node reports a
hook timeout as a hook failure, which is a plain failure. Also covers a
subtest stuck in its own after hook in the timeout fixture, and trims the
remaining multi-line comments.
Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts Outdated
…e timeout

Arming the drain with the full timeout again let a test whose chain is
stuck spend up to twice its timeout, which past 2500ms loses to bun:test's
own watchdog and reports the wrong error. The body phase's stop controller
now reports how much of the budget is left (hooks in between stay exempt,
as before) and the drain is armed with that, so the whole thing still fits
in one timeout like it did when the drain ran inside the body race.
Comment thread src/js/node/test.ts
Comment on lines +2381 to 2385
if (node.cancelled) failure ??= cancelledByParentFailure();

const bodyFailure = failure;
failure = applyExpectFailure(node, failure);
const acceptedXfail = bodyFailure !== undefined && failure === undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 An in-flight subtest cancelled by its parent has the cancelledByParent error fed through applyExpectFailure here, so a subtest with expectFailure (explicit or inherited from the parent, line 1604) reports test:pass and does not increment parent.failedSubtests — Node reports it as cancelled, and so does the queued path via finishCancelled() at line 753, which skips the xfail check. Repro: test('p', async t => { await t.test('a', () => {}); t.test('slow', { expectFailure: true }, () => new Promise(() => {})); })slow is accepted as an xfail and p passes when it should fail with 1 subtest failed. Guarding the xfail check on !node.cancelled (or excluding failure?.failureType === 'cancelledByParent') makes the two cancellation paths agree.

Extended reasoning...

What the bug is

This PR introduces two paths by which a parent cancels a pending subtest: a subtest still queued on subtestChain when its turn comes goes through finishCancelled() (line 753), and a subtest whose body is already running has its stop promise rejected with cancelledByParentFailure() (line 2474 in cancelUnfinishedSubtests). The first path sets passed = false directly and never consults expectFailure. The second path lands in executeTestNode at line 2381 (if (node.cancelled) failure ??= cancelledByParentFailure()), and the very next line feeds that failure into applyExpectFailure(node, failure).

applyExpectFailure with no validator (parseExpectFailure(true) returns {label: undefined, match: undefined}) accepts any non-undefined failure and returns undefined — so acceptedXfail = true, node.passed = true, the child emits test:pass via reportNodeToRunParent, and executeTestNode returns undefined. Back in scheduleSubtest, failure === undefined means parent.failedSubtests is not incremented and the parent may pass. The same applies to a subtest cancelled during its beforeEach hooks: line 2332's !node.cancelled gate skips the body block, line 2381 sets failure, and line 2384 accepts it.

Why existing code doesn't prevent it

The comment at line 2380 ("an earlier failure wins") and the ??= are about not overwriting a body error with the cancellation — they don't address the interaction with expectFailure. Nothing between line 2381 and line 2384 checks node.cancelled or the failure's failureType. applyExpectFailure itself has no cancellation carve-out. And because expectFailure is inherited from the parent (line 1604: parseExpectFailure(options.expectFailure) || parent?.expectFailure), a user doesn't even have to put the option on the stale subtest to hit this — an xfail parent that forgets an await propagates it.

Step-by-step proof

test('p', async t => {
  await t.test('a', () => {});
  t.test('slow', { expectFailure: true }, () => new Promise(() => {}));
});
  1. a completes and resolves p.subtestsSettled (first settle). slow is chained behind a and starts on the next microtask; its body awaits a never-settling promise.
  2. p's body returns; await p.subtestsSettled?.promise is already resolved, so the body race completes with failure === undefined.
  3. p's afterEach/after hooks run, then settleSubtests(p): unfinishedSubtests = {slow}, one setImmediate grace, then cancelUnfinishedSubtests(p) sets slow.cancelled = true and calls slow.stop.reject(cancelledByParentFailure()).
  4. In slow's executeTestNode: the body race rejects, failure = cancelledByParent. Line 2381 is a no-op (failure already set). Line 2384: applyExpectFailure(slow, failure)slow.expectFailure = {label: undefined, match: undefined}, so it returns undefined. acceptedXfail = true, slow.passed = true, slow.error = cancelledByParent.
  5. reportNodeToRunParent(slow, ...) emits test:pass with expectFailure: true. executeTestNode returns undefined. scheduleSubtest sees failure === undefined and does not touch p.failedSubtests.
  6. Back in p's settleSubtests, the drain completes. p.failedSubtests === 0, so p passes.

Contrast with the queued path: insert a third subtest t.test('queued', { expectFailure: true }, () => {}) after slow. It's still on subtestChain when cancelled, so its link runs finishCancelled(queued): passed = false, test:fail with failureType: 'cancelledByParent', and p.failedSubtests++. Same option, opposite verdict, decided only by whether the body had started.

Node v26.3.0 reports slow as test:fail / cancelledByParent and fails p with 1 subtest failed. Node's #cancel() calls this.fail() (which sets endTime) before run()'s catch block observes the abort, so the subsequent this.pass() from the xfail branch is a no-op — Node never lets expectFailure swallow a cancellation.

Impact

A false pass in a Node-compat test runner: an { expectFailure: true } subtest whose await was forgotten (the exact bug this PR exists to catch) reports as an accepted xfail, and its parent may pass. Under run(), it lands in counts.passed instead of counts.cancelled. The trigger is narrow — expectFailure + not-first-batch + forgotten await — so this is a nit rather than blocking, but per REVIEW.md "Fix the whole class in the same PR" the two cancellation paths introduced here should agree.

Fix

Skip the xfail check when the node was cancelled, matching finishCancelled:

const bodyFailure = failure;
if (!node.cancelled) failure = applyExpectFailure(node, failure);
const acceptedXfail = bodyFailure !== undefined && failure === undefined;

(or equivalently exclude failureType === 'cancelledByParent' inside applyExpectFailure). None of the fixtures cover expectFailure × cancellation, so a case in fixture 30 would lock this in.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inconsistency between the two paths is real, but node resolves it the other way around. #cancel() goes through fail(), and fail() is where expectFailure is applied, so in node a cancelled expectFailure subtest is an accepted expected failure on both paths. Checked on v26.3.0 with TAP output:

test("p", async t => {
  await t.test("first", () => {});
  t.test("running xfail", { expectFailure: true }, () => new Promise(() => {}));
  t.test("queued xfail", { expectFailure: true }, () => {});
});
    ok 1 - first
    ok 2 - running xfail # EXPECTED FAILURE
    ok 3 - queued xfail # EXPECTED FAILURE
ok 1 - p

The same holds when the option is inherited from an expectFailure parent. So the in-flight path here (cancellation fed through applyExpectFailure()) already matches node; it is the queued path, finishCancelled(), that does not, since it reports the cancellation as a failure regardless. I will make finishCancelled() apply expectFailure as well and add this case to fixture 30 (it passes under node as shown above, and it fails on the current revision because the queued one is counted against the parent).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants