Skip to content

fix(cli): send a timed-out mocha test's finish that lands after the worker's final flush (SDK-7843) - #267

Open
AakashHotchandani wants to merge 3 commits into
mainfrom
fix/SDK-7843-late-test-finish
Open

AakashHotchandani wants to merge 3 commits into
mainfrom
fix/SDK-7843-late-test-finish

Conversation

@AakashHotchandani

@AakashHotchandani AakashHotchandani commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What is this about?

On the CLI flow, a mocha test that hits mocha's timeout never gets a TestRunFinished. Test Hub keeps that test_run open, so the build ends as timeout about 60 minutes after the run. Test Reporting also shows the timed-out test as unknown rather than failed.

A customer hit this (SDK-7843). On 9.36.0–9.39.2, 100% of their WDIO builds ended finished. Since 9.39.3, 47 of 55 ended timeout.

Why it surfaced only now:

Why it happens. TestHubModule defers the mocha TEST/POST past the after-each window. The deferred finish is flushed at the next test's first event, or for the worker's last test from service.after().

On a mocha timeout, mocha gives up on the test while its body is still pending. wdio's afterTest, which emits TEST/POST, fires only once that body settles. With bail that happens during service.after(), after its flush has already run against an empty map. The late stash then has nothing left to send it:

after() flush + EXECUTE/POST timed-out test's TEST/POST deferred worker teardown
customer build #753, worker 0-4 16:32:00.276 16:32:00.415 16:32:00.424
customer build #753, worker 0-3 16:32:20.041 16:32:20.190 —
repro, stock 9.39.2 16:33:50.594 16:33:51.805 16:33:51.866

Customer binary log for #753: 8 TestRunStarted vs 6 TestRunFinished.

  • The two missing finishes are the first attempts of the two tests that hit Timeout of 300000ms exceeded.
  • The stop POST returned 200 at 16:34:36, yet finished_at = 17:34:52 (the idle reap).

The fix, in TestHubModule plus two call sites in service.after():

  • finishWorker() replaces the bare flushPendingTestFinishEvent() call at the start of after(). It flushes, then marks the worker as ending. After that, a TEST/POST is sent immediately instead of deferred, since no later flush exists.
  • trackLateWork(): service.afterTest registers its whole CLI branch through it (LOG_REPORT, TEST/POST, then the bail skip cascade). It is tracked only once the worker is ending.
  • awaitLateTestFinishes() runs near the end of after(), next to the classic path's sweepUnfinished() and before teardown:
    • It waits, bounded at 5 s (LATE_TEST_FINISH_WAIT_MS), until every test whose TEST/PRE was seen has reported, late afterTest work has settled, and late sends have landed.
    • A test still open after the bound is closed with a synthetic failed TEST/POST (reason Test did not finish before the session ended (incomplete).), the CLI counterpart of sweepUnfinished(). A straggling real finish for it is then dropped.
    • It returns at once when nothing is open, which is the normal case.

Unchanged: a finish during the run is still deferred, so custom tags set in afterEach still make it into the payload.

Not in v8: the v8 line sends TEST/POST inline (no deferral), so it doesn't have this bug. No v8 port is needed.

Verification

Real WDIO 9 + mocha runs on Automate (chrome/Win11, CLI flow, Test Reporting on), with mochaOpts: { timeout: 10000, bail: true } and a test that waits 40 s for a missing element. Each build was read back from /ext/v1/builds/<uuid>/testRuns:

build spec rollup
w6oktdknrww4qksrmhhwv8rhilwdc6ka4dlf0xi3 stock 9.39.2 pass → timeout passed 1, failed 0, unknown 1 (never closed)
kptjob3frg5grhuomlzrhj2rhihtrjpfwfhklvzr this PR (1st commit) pass → timeout passed 1, failed 1, unknown 0
daoyulfmxsq7w9mcounalwqxqubem2bhf55dygtx this PR (review fixes) pass → timeout → bail-skipped passed 1, failed 1, skipped 1, unknown 0

In the third run the bail cascade's TEST/POST landed 1.27 s after the timed-out test's (17:07:24.082 vs 17:07:22.808), still before teardown at .135. Waiting for the whole late afterTest (review finding) is what covers that.

Tests:

  • New file: tests/cli/modules/testHubModule.lateFinish.test.ts has 8 cases:
    • a late finish after finishWorker() is sent;
    • the wait covers a finish arriving 150 ms later;
    • a never-finishing test gets exactly one synthetic failed finish;
    • a straggler after that close is dropped;
    • the bail cascade is waited for (fails on the previous open-tests-only loop);
    • work registered before the worker is ending is not waited for;
    • a mid-run finish is still deferred;
    • it returns immediately when nothing is open.
  • Updated: tests/service.afterSkipOrdering.test.ts mocks finishWorker, keeps the SDK-7493 drain-before-flush assertion, and adds the call order finishWorker < awaitLateTestFinishes < Listener.onWorkerEnd.

Checks:

  • tests/cli/modules, service*.test.ts, skipReporter*: the same 43 failures with and without this PR, compared by test name. All are pre-existing network-dependent service.test.ts cases; none is new.
  • tests/cli: 4 failed / 414 passed. The 4 are the same pre-existing cliUtils update-CLI and staleBinary failures as on main.
  • tsc -p tsconfig.prod.json --noEmit and eslint are clean.

Related: #264 / #265 (the cosmetic resolveInstance … LOG ERROR from the same ticket).

Related Jira task/s

SDK-7843

Release (mandatory for every PR — required for the ready-for-review label)

Version bump: (required — tick exactly one)

  • minor (backwards-compatible feature)
  • patch (bug fix or other small change)

Release notes type: (optional)

  • New Feature
  • Bug Fix
  • Other Improvement

Release notes (customer-facing): (optional but encouraged)

  • Fixed builds ending as "timeout" in Test Reporting, about an hour after the run, when a mocha test failed by exceeding its timeout (especially with bail). The timed-out test is now reported as failed, and the build closes when the run ends.

Release notes (internal): (required — engineer-facing; what actually changed / why)

  • TestHubModule.finishWorker() replaces the bare flushPendingTestFinishEvent() in service.after(). It flushes and sets workerEnding, after which a mocha TEST/POST is sent immediately instead of being stashed. A timed-out test's afterTest fires once its still-pending body settles, which with bail is during after(), after the final flush. Its deferred finish was stranded, so the test_run stayed open and the build was reaped as timeout (~60 min).
  • New TestHubModule.awaitLateTestFinishes() (bounded 5 s, static readonly LATE_TEST_FINISH_WAIT_MS), called near the end of after() before teardown. It waits for open tests (TEST/PRE without TEST/POST), late afterTest work registered via trackLateWork() (including the bail cascade), and late sends. A test still open after the bound gets a synthetic failed TEST/POST ("incomplete"), mirroring the classic sweepUnfinished(); a straggling real finish for it is dropped.
  • Surfaced by fix/SDK-7138-wdio-upload-attachment and security/chalk-mal-2025-46969 #261 (4eb12b3): updateURLSForGRR became null-safe, so the CLI now boots for accounts whose bin-session config has no apis, and those accounts reached the CLI-only deferral path. v8 has no deferral and is unaffected.

Checklist

  • Ready to review
  • Has it been tested locally?

PR Validations

Run Tests: Comment RUN_TESTS to trigger sanity tests.

🤖 Generated with Claude Code

…orker's final flush (SDK-7843)

The mocha TEST/POST (TestRunFinished) is deferred past the after-each
window and flushed at the next test or from service.after(). A test that
hits mocha's timeout keeps running after mocha moves on, so its afterTest
fires only once the body settles. With `bail` that happens during
service.after(), after the final flush has already run against an empty
map. The late stash was never sent, Test Hub kept the test_run open, and
the build was reaped as `timeout` about 60 minutes later.

TestHubModule now:
- finishWorker(): called by service.after() in place of the bare flush.
  It flushes and marks the worker as ending.
- sends a TEST/POST immediately, instead of deferring it, once the
  worker is ending.
- awaitLateTestFinishes(): called near the end of service.after(), before
  teardown. It waits (bounded, 5 s) for every test whose TEST/PRE was seen
  to report its TEST/POST, then for those sends to land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AakashHotchandani
AakashHotchandani requested a review from a team as a code owner October 5, 2026 16:43
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Central YAML (base), Organization UI (inherited), Workspace UI (inherited)
  • Review profile: ASSERTIVE
  • Plan: Enterprise
  • Run ID: be57c337-ae89-4099-b1b0-fb3e5f71e3f8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@AakashHotchandani AakashHotchandani left a comment

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.

Automated SDK PR Review

Verdict: ⚠️ Fix 2 issues — (1) Send a synthetic failed TEST/POST for any test still open after the 5 s bound (testHubModule.ts:214). Otherwise a test whose body never settles still ends the build as timeout. (2) Make awaitLateTestFinishes wait for the whole late afterTest, including its bail-skip cascade, not only for the timed-out test's TEST/POST (testHubModule.ts:217).

Summary: 0 critical · 2 warnings · 3 suggestions across 4 files reviewed.

Intent: On the CLI flow, TestHubModule holds back a mocha test's TEST/POST (TestRunFinished) until after the after-each window, and sends it at the next test's first event or from service.after(). A test that hits the mocha timeout runs its afterTest only when its pending body settles. With bail, that happens during service.after(), after the final flush has already run, so the finish was never sent. Test Hub then left the test_run open and stamped the build timeout about 60 minutes later (SDK-7843; the CLI path started reaching this customer after #261). The PR adds finishWorker(), which flushes and then sends any later TEST/POST immediately, and awaitLateTestFinishes(), which waits up to 5 s before teardown for every test whose TEST/PRE was seen to report TEST/POST and be sent. Deferral during the run is unchanged. Affected products: Test Reporting (Observability) on the CLI/gRPC path. Affected frameworks: WDIO v9 mocha (the deferral is mocha-only; the open-test wait also covers CLI cucumber). Components: cli/modules/testHubModule.ts, service.ts after(). No proto or binary change.

Risk: Medium

The fix is correct for the failure the customer actually hit, where the late TEST/POST landed within about 1 s of the flush, and it does not regress ordinary runs. The verdict rests on two remaining gaps, and each one still ends in the same ~60-minute timeout reap. First, a timed-out body that never settles within 5 s still gets no TestRunFinished, and the CLI path has no synthetic close equivalent to the classic sweep. Second, the wait can return before a late afterTest's bail-skip cascade has finished sending. Rovo enrichment unavailable — review based on local SDK docs only.

See inline comments below for full Problem and Suggested Fix detail on each finding.

Generated by Automated SDK PR review.

while (this.openTestUuids.size > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
if (this.openTestUuids.size > 0) {

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.

⚠️ Warning — [OBSERVABILITY] A timed-out test whose body never settles within the 5 s bound still gets no TestRunFinished

Problem

awaitLateTestFinishes() only waits. When the bound expires with openTestUuids still non-empty, it logs at debug level (line 215) and returns. Nothing sends a TEST/POST for those uuids, so the TestRunStarted already sent at TEST/PRE has no matching TestRunFinished. Test Hub then reaps the build as timeout about 60 minutes later, which is the SDK-7843 symptom this PR sets out to fix.

The fix depends on the timed-out body settling within 5 s of after() starting. It did in the evidence: the late POST landed 139–150 ms after the flush on customer workers 0-4 and 0-3, and about 1.2 s after it in the repro. Nothing guarantees it, though. Three cases still leave the run open:

  • an infinite await;
  • a hung driver call, which only wdio's connectionRetryTimeout (default 120 s) bounds;
  • a waitUntil/waitForDisplayed whose own timeout exceeds the mocha timeout by more than 5 s.

Unit case 3 (gives up after the bound when a test never finishes) asserts 0 sends, so the test suite now locks the gap in.

The classic path does not have this gap. Per the observability docs, openRunsJournal / finalizeOrphanedRuns close "any runs that never received a TestRunFinished event (e.g., due to a test timeout …)", and sweepUnfinished() sends a synthetic finish. That sweep explicitly skips CLI-path tests (insights-handler.ts:543-548: "Any genuine CLI-path orphan is a binary-side concern"). The binary does not close it either: the customer's stop POST returned 200 at 16:34:36, yet finished_at was the 17:34:52 idle reap. So even after this PR, the CLI path has no terminal close for an orphaned test.

Suggested Fix

Send a synthetic terminal TEST/POST for every test still open after the bound. The state this needs is already available. Keep the TEST/PRE args instead of only the uuid. args.instance is that test's own TestFrameworkInstance: INIT_TEST puts a fresh object in the tracked slot, as the pendingTestFinishes docs note, so the stored one stays valid.

private openTests: Map<string, Record<string, unknown>> = new Map()
// TEST/PRE:  this.openTests.set(String(testUuid), args)
// TEST/POST: this.openTests.delete(String(testUuid))

// in awaitLateTestFinishes(), after the wait loop:
const reason = 'Test did not report a finish before worker teardown (timed out and never settled)'
const closes = [...this.openTests].map(([uuid, args]) => {
    (args.instance as TestFrameworkInstance).updateMultipleEntries({
        [TestFrameworkConstants.KEY_TEST_RESULT]: 'failed',
        [TestFrameworkConstants.KEY_TEST_FAILURE_REASON]: reason,
        [TestFrameworkConstants.KEY_TEST_FAILURE]: [{ backtrace: [reason] }],
        [TestFrameworkConstants.KEY_TEST_ENDED_AT]: new Date().toISOString(),
    })
    this.syntheticallyClosed.add(uuid)
    return this.sendTestFrameworkEvent(args, { testFrameworkState: 'TEST', testHookState: 'POST', uuid })
})
this.openTests.clear()
await Promise.all(closes)

In the TEST/POST branch, drop any POST whose uuid is in syntheticallyClosed, so a straggler that arrives after the synthetic close is not sent a second time. Then change unit case 3 to expect exactly one failed TEST/POST for never-finishes.

Confidence: 🟢 Grounded in the observability docs (an orphaned run must be closed, and the classic path does this through finalizeOrphanedRuns/sweepUnfinished) and in sdk-anti-pattern #23 (fixing one path but not the parallel one). The missing send can be read directly from the code, and the PR's own unit case 3 asserts it.

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.

Valid, fixed in 6b0453a. If a test is still open when the bound expires, awaitLateTestFinishes() now closes it with a synthetic failed TEST/POST, using the stored TEST/PRE args (that test's own instance) and its pinned uuid. It sets result: failed with the reason Test did not finish before the session ended (incomplete)., which mirrors the classic sweepUnfinished(). If the real TEST/POST straggles in after that, it is dropped (syntheticallyClosed), so the test is never closed twice. Unit case 3 now expects exactly one failed finish, and a new case covers the straggler drop.

if (this.openTestUuids.size > 0) {
this.logger.debug(`awaitLateTestFinishes: no TEST/POST within ${waitMs}ms for ${[...this.openTestUuids].join(', ')}`)
}
await Promise.all([...this.lateFinishSends])

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.

⚠️ Warning — [CORRECTNESS] awaitLateTestFinishes can return while the late test's bail cascade is still emitting skip reports

Problem

The late afterTest emits more than TEST/POST. On the CLI branch, service.afterTest (service.ts:674-676) runs LOG_REPORT/POST, then TEST/POST, then await this.reportBailSkippedTests(test, results). A timed-out test under bail has passed: false and no retry pending, so this calls reportSuiteSkipped from the spec's root suite. Each unrun test then gets INIT_TEST, TEST/PRE (which sends TestRunStarted), LOG_REPORT/POST and TEST/POST.

The wait loop watches only openTestUuids. The timed-out test's uuid is removed inside TestHubModule's TEST/POST observer, but trackEvent(TEST, POST) keeps awaiting the other modules' observers (runHooks → notifyObserver) before reportBailSkippedTests starts. Those observers do real I/O. A 50 ms tick that falls in this gap sees an empty set and leaves the loop.

Promise.all([...this.lateFinishSends]) then takes a snapshot of the sends present at that moment, so skip finishes added later are never awaited. after() goes on to Listener.onWorkerEnd() and teardown while the cascade is still running. A skipped test that has sent TestRunStarted but not TestRunFinished is the same kind of open test_run, and it ends in the same ~60-minute timeout reap.

The PR's repro does not cover this case: the timed-out test is the last test in its spec, so the cascade has nothing to report.

Suggested Fix

Wait for the whole late afterTest, not just its TEST/POST. Once finishWorker() has run, the CLI branch of afterTest should register its own promise with TestHubModule. awaitLateTestFinishes should then loop until open tests, registered late work and lateFinishSends are all empty, or the deadline passes, and re-read lateFinishSends on every pass:

while ((this.openTestUuids.size > 0 || this.lateWork.size > 0 || this.lateFinishSends.size > 0) && Date.now() < deadline) {
    await new Promise((resolve) => setTimeout(resolve, 50))
}
await Promise.all([...this.lateFinishSends])

Add a unit case for this sequence: PRE(timed-out) → finishWorker() → POST(timed-out), then, after an await gap, PRE/POST for a bail-skipped test. Assert that both finishes are sent before awaitLateTestFinishes() resolves.

Confidence: 🟡 Agent analysis of the await ordering across service.afterTest, runHooks and reportBailSkippedTests. It has not been reproduced at runtime, which would need a bail spec where the timed-out test is not the last one.

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.

Valid, fixed in 6b0453a, and a live run confirmed it isn't only theoretical. service.afterTest now registers its whole CLI branch (LOG_REPORT, TEST/POST and reportBailSkippedTests) through TestHubModule.trackLateWork(), which tracks it only once the worker is ending. awaitLateTestFinishes() loops until open tests, late work and late sends are all empty, re-reading lateFinishSends on every pass.

Live run (chrome/Win11, mocha timeout 10 s + bail, timed-out test in the middle of the spec): the bail-skipped test's TEST/POST landed 1.27 s after the timed-out test's (17:07:24.082 vs 17:07:22.808), still before teardown at .135. The previous loop would have returned about 50 ms after the first finish. Build daoyulfmxsq7w9mcounalwqxqubem2bhf55dygtx from /ext/v1: passed 1, failed 1, skipped 1, unknown 0.

The new unit case (PRE timed-out → finishWorker() → POST, then after a 120 ms gap PRE/POST for a bail-skipped test) fails when I put back the old open-tests-only loop, and passes with the fix.

const flushPendingTestFinishEvent = vi.fn().mockResolvedValue(undefined)
// after() flushes through finishWorker() (SDK-7843), which also arms the immediate-send path.
const finishWorker = vi.fn().mockResolvedValue(undefined)
const awaitLateTestFinishes = vi.fn().mockResolvedValue(undefined)

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.

💡 Suggestion — [TESTING] awaitLateTestFinishes is mocked in the after() ordering test but never asserted

Problem

This mock exists only so after() does not throw. No assertion checks that it is called once, after finishWorker(), and before Listener.onWorkerEnd() and _cliTestUuids.clear(). That position is what lets a late afterTest still restore its snapshotted uuid.

The five new TestHubModule cases call finishWorker() and awaitLateTestFinishes() directly. As the PR description says, they fail trivially on the unfixed source. None of them drives the actual ordering bug: after() starts, the flush runs, a late afterTest arrives, and its finish must be sent before teardown.

No case checks that openTestUuids empties on the skip-reporter drain path either. If it does not, every CLI mocha worker pays the full 5 s.

Suggested Fix

Add three things:

  • A call-order assertion: finishWorker < awaitLateTestFinishes < Listener.onWorkerEnd.
  • One after()-level case with the real TestHubModule and a mocked gRPC client. Start a delayed promise before after() that emits the timed-out test's TEST/POST, and assert that the TEST/POST send happens before onWorkerEnd.
  • A case where drainSkipReports() emits a skip (INIT_TEST/PRE … TEST/POST). Assert that awaitLateTestFinishes() then returns within a few ms.

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.

Partly applied in 6b0453a:

  • Call order: service.afterSkipOrdering.test.ts now asserts finishWorker < awaitLateTestFinishes < Listener.onWorkerEnd.
  • New TestHubModule cases: bail cascade (mutation-checked, see the reply above), straggler after synthetic close, and work registered before the worker is ending, which is not waited for.

Not added, and why:

  • An after()-level case with the real TestHubModule. What it would prove (a late afterTest landing mid-after() and being sent before teardown) is proven end-to-end by the three real-session builds: stock w6oktdkn… with unknown 1, and fixed kptjob3f… / daoyulfm… with unknown 0. A unit harness would have to fake wdio's timer-driven afterTest anyway, so it would assert the fake rather than the race.
  • A skip-drain case. drainSkipReports() is awaited at the top of after(), before finishWorker(), and each drained skip emits its TEST/PRE and TEST/POST through the same onAllTestEvents observer. So openTests is already balanced when awaitLateTestFinishes() runs. That is the path the existing returns immediately at worker end when every started test has finished case covers (it returns in under 500 ms against a 5 s bound).

* instance so late custom-tag merges are included. Called from onAllTestEvents at the
* next test's boundary and from service.after() at worker end.
*/
/**

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.

💡 Suggestion — [DOCS] flushPendingTestFinishEvent's JSDoc now sits above finishWorker

Problem

The new methods were inserted between the existing doc block ("Send a deferred TEST/POST (TestRunFinished) event, if one is pending…", lines 187-193) and flushPendingTestFinishEvent() (line 222). Two JSDoc blocks are now stacked above finishWorker(), and flushPendingTestFinishEvent() has none, so IDE hovers and generated docs show the wrong description for both methods.

Suggested Fix

Move lines 187-193 so they sit directly above flushPendingTestFinishEvent(): Promise<void> | undefined {.

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.

Valid, fixed in 6b0453a. The "Send a deferred TEST/POST…" JSDoc is back directly above flushPendingTestFinishEvent(), and finishWorker(), trackLateWork() and awaitLateTestFinishes() each have only their own block.

await Promise.all([...this.lateFinishSends])
}

static LATE_TEST_FINISH_WAIT_MS = 5000

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.

💡 Suggestion — [CONFIG] LATE_TEST_FINISH_WAIT_MS is a mutable static declared in the middle of the class

Problem

The bound is a named constant, which is good, but it is a writable static declared between two methods. Any code can reassign it at runtime, and it is hard to find next to MODULE_NAME. It is also not a hard ceiling on after(): the final Promise.all(lateFinishSends) can add up to three gRPC attempts per send, with 200 ms and 400 ms backoff between them.

Suggested Fix

Declare it as static readonly LATE_TEST_FINISH_WAIT_MS = 5000 next to MODULE_NAME (line 28). Update the doc comment to say the 5 s covers only the wait for the finish to arrive, and that the send itself is not included.

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.

Valid, fixed in 6b0453a. It is now static readonly LATE_TEST_FINISH_WAIT_MS = 5000, declared next to MODULE_NAME. Its doc now says it bounds only the wait for late finishes to arrive, and that the sends keep their own retry/backoff in flushPendingTestFinishEvent. INCOMPLETE_TEST_REASON is also declared there as static readonly.

… at worker end (SDK-7843)

Review follow-ups on the late test-finish fix:
- Close a test that never reports its finish within the bound with a
  synthetic failed TEST/POST (reason "Test did not finish before the
  session ended (incomplete)."). This mirrors the classic path's
  sweepUnfinished(). A straggling real TEST/POST for it is then dropped.
- Wait for the whole late afterTest, not only its TEST/POST:
  service.afterTest registers its CLI branch via trackLateWork(), so the
  bail cascade's skip reports are sent before teardown. A live run hit this
  gap: the cascade's finish landed 1.27 s after the timed-out test's.
- LATE_TEST_FINISH_WAIT_MS is now static readonly, next to MODULE_NAME,
  and its doc says it bounds only the arrival wait.
- flushPendingTestFinishEvent's JSDoc is back above it.
- Tests: synthetic close, straggler drop, bail-cascade ordering (fails on
  the previous loop), untracked pre-ending work, and the after() call
  order finishWorker < awaitLateTestFinishes < onWorkerEnd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

🔴 SDK PR Review gate is red. Pending:

  • The SDK PR Review Agent has not been run on the current head commit yet — run the SDK PR Review Agent (its verdict is advisory; this gate only requires that it ran on the latest commit).

It turns green once the SDK PR Review Agent has run on the current head commit (any verdict — the gate only requires that the review ran). A native reviewer approval is separately required by branch protection before merge.

@AakashHotchandani AakashHotchandani left a comment

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.

Automated SDK PR Review (re-review @ 6b0453ab)

Summary

Intent: On the CLI flow, TestHubModule holds back a mocha test's TEST/POST (TestRunFinished) until after the after-each window. It sends it at the next test's first event, or from service.after() for the worker's last test. A test that hits the mocha timeout runs its afterTest only when its pending body settles. With bail, that happens during service.after(), after the final flush, so the finish was never sent. Test Hub then stamped the build timeout about 60 minutes later (SDK-7843). The PR adds three pieces:

  • finishWorker(): flushes, then sends any later TEST/POST immediately.
  • trackLateWork(): service.afterTest registers its whole CLI branch (LOG_REPORT, TEST/POST, bail cascade) through it once the worker is ending.
  • awaitLateTestFinishes(): before teardown, waits up to 5 s until open tests, late work and late sends are empty. Any test still open is then closed with a synthetic failed TEST/POST ("incomplete"), the CLI counterpart of the classic sweepUnfinished(). A straggling real finish is dropped.

Deferral during the run is unchanged. Affected products: Test Reporting (Observability) on the CLI/gRPC path. Affected frameworks: WDIO v9 mocha; the open-test wait and synthetic close also cover CLI cucumber. Components: cli/modules/testHubModule.ts, service.ts (afterTest, after()). No proto or binary change.
Re-review scope: new head 6b0453ab (prior review at 1a76bfc5). The delta is one commit: fix(cli): close never-finishing tests and wait for late bail cascades….
Risk: Medium
0 critical · 1 warning · 1 suggestion | Files reviewed: 5

Rovo enrichment unavailable — review based on local SDK docs only.

Prior findings status

# Prior finding Status Evidence at 6b0453ab
W1 🟢 A test still open after the 5 s bound got no TestRunFinished RESOLVED See W1 note below.
W2 🟡 awaitLateTestFinishes could return mid bail-cascade PARTIAL Fixed for the case the review described, an afterTest that starts after finishWorker(). See W2 note below.
S1 Assert call order; add after()-level and drain tests DECLINED-ACCEPTABLE (partly applied) See S1 note below.
S2 flushPendingTestFinishEvent JSDoc misplaced RESOLVED Its doc is at :277-283, directly above :284. finishWorker (:211-215), trackLateWork (:220-225) and awaitLateTestFinishes (:232-240) each have only their own block.
S3 Mutable static mid-class RESOLVED static readonly LATE_TEST_FINISH_WAIT_MS = 5000 at :35, next to MODULE_NAME. Its doc now says it bounds only the arrival wait, not the sends. INCOMPLETE_TEST_REASON is static readonly at :38.

W1 (RESOLVED). closeOpenTestsAsIncomplete() (testHubModule.ts:252-267) uses the stored TEST/PRE args, which hold the test's own instance (openTests, :84, set at :173).

  • Fields. It sets result failed, failure [{backtrace:[reason]}], failure_reason, failure_type UnhandledError and ended_at. These are the same keys the real mocha builder writes (wdioMochaTestFramework.ts:254-259 loadTestResult, plus :88-90 ended_at).
  • Parity with the classic path. It matches sweepUnfinished exactly: insights-handler.ts:613-614 sends result failed with getFailureObject(new Error('Test/hook did not finish before the session ended (incomplete).')).
  • Pinned uuid. The uuid is pinned through stateOverride.uuid, which also overrides test_uuid inside event_json (:351-354).
  • Sent and awaited before teardown. The send is added to lateFinishSends and awaited at :249, before onWorkerEnd.
  • No double finish. A straggling real POST is dropped at the single TEST/POST entry point (:175-179), which runs before both the deferred stash and the immediate send. The flush can therefore never hold a closed uuid. A real POST already in flight has removed its uuid from openTests (:180) before it is sent, so it is never also closed synthetically.
  • Tests. Unit cases :122 and :138 cover the close and the straggler drop.

W2 (PARTIAL).

  • Fixed. service.afterTest wraps LOG_REPORT, TEST/POST and reportBailSkippedTests in finish() and passes it to trackLateWork (service.ts:674-682). The loop at :242 re-reads openTests, lateWork and lateFinishSends on every pass. The live run daoyulfm… saw the cascade's TEST/POST 1.27 s after the timed-out test's, still before teardown. The unit case at :150 fails on the old loop: it would exit at about 50 ms, before the 120 ms cascade.
  • Still open. trackLateWork records work only if (this.workerEnding) (:226). An afterTest that started before finishWorker() still has an untracked cascade. The test at :171 locks this in. See Warning 1.

S1 (DECLINED-ACCEPTABLE, partly applied).

  • Added. The call-order assertion finishWorker < awaitLateTestFinishes < onWorkerEnd (service.afterSkipOrdering.test.ts:107-116), plus the cascade, straggler and pre-ending-work cases.
  • Declined: the after()-level case with the real TestHubModule. The author's reason holds. A unit harness would have to fake wdio's timer-driven afterTest, and three real Automate builds (stock w6oktdkn… with unknown 1; fixed kptjob3f… and daoyulfm… with unknown 0 and the cascade covered) prove the end-to-end ordering better.
  • Declined: the drain case. drainSkipReports() is awaited before finishWorker(), and drained skips emit PRE and POST through the same observer, so the existing "returns immediately" case (:193) stands in for it.

Verdict

✅ Good to go: W1, S2 and S3 are resolved, and S1 is acceptably declined.

  • The synthetic incomplete close and the late-work wait fix SDK-7843 for the observed timing.
  • One non-blocking item is left for human judgment (🟡, timing not reproduced). A late afterTest that starts before finishWorker() is not tracked, so its bail cascade could outlive the wait (testHubModule.ts:226). Dropping the workerEnding gate in trackLateWork is a one-line hardening.

See inline comments below for detail on the non-blocking items.

Generated by Automated SDK PR review.

* bail cascade (skip reports after the timed-out test's own TEST/POST) is waited for too.
*/
trackLateWork<T>(work: Promise<T>): Promise<T> {
if (this.workerEnding) {

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.

⚠️ Warning — [CORRECTNESS] A late afterTest that starts before finishWorker() is not tracked, so its bail cascade can still outlive awaitLateTestFinishes

Problem

trackLateWork() adds the afterTest work only if (this.workerEnding). service.afterTest calls it synchronously when it is entered (service.ts:682), so the gate is checked when the timed-out body settles, not when the cascade runs.

If the body settles in the window between the end of the mocha run and finishWorker(), afterTest is entered with workerEnding === false and is never tracked. That window includes the rest of wdio's runner work before the after hooks, and after() itself up to the await drainSkipReports(). This case then plays out as follows:

  • The timed-out test's TEST/POST is still handled: it is either stashed and flushed by finishWorker(), or sent immediately and tracked in lateFinishSends.
  • reportBailSkippedTests then runs untracked. Between the timed-out test's POST and each skipped test's TEST/PRE, openTests and lateFinishSends can both be empty.
  • In the author's live run that gap was about 1.27 s (17:07:22.808 → 17:07:24.082), so a 50 ms poll that lands in it returns.
  • A skipped test whose TestRunStarted is sent after that never gets a finish. It is not in openTests when the synthetic close runs, so it ends in the same ~60-min timeout reap.

When the body settles relative to after() is outside the SDK's control. That is the PR's own premise: the body is "still running when after() starts". The customer timings (late POST 139–150 ms after the flush, with LOG_REPORT/POST in front of it) do not rule out an entry before the flush. The test does not track work registered before the worker is ending (testHubModule.lateFinish.test.ts:171) asserts this gap as intended behaviour.

Suggested Fix

Track unconditionally. trackIn already removes each promise when it settles. wdio awaits every normal afterTest inside the test wrapper, so when after() runs, the only unsettled entries are afterTests of tests mocha abandoned, which are exactly the ones to wait for. The 5 s deadline still bounds the wait.

trackLateWork<T>(work: Promise<T>): Promise<T> {
    // Untracks itself on settle, so only work still running at worker end is waited for.
    this.trackIn(this.lateWork, work)
    return work
}

Change the case at :171 to register work before finishWorker(), release it 120 ms into awaitLateTestFinishes(), and emit a bail-skipped PRE/POST from it. Assert that the skipped test's POST is sent. Keep a separate check that settled pre-ending work returns at once.

Confidence: 🟡 The untracked path is visible in the code and pinned by the unit case at :171. The author's live run measured the cascade gap at 1.27 s. Whether wdio can actually enter a timed-out test's afterTest before after() reaches finishWorker() has not been reproduced: the live run's body settled after the flush.

[TestFrameworkConstants.KEY_TEST_ENDED_AT]: new Date().toISOString()
})
this.syntheticallyClosed.add(uuid)
this.trackIn(this.lateFinishSends, this.sendTestFrameworkEvent(args, { testFrameworkState: 'TEST', testHookState: 'POST', uuid }))

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.

💡 Suggestion — [RESILIENCE] The synthetic close is a single send with no retry, and an exception on one test stops the rest from being closed

Problem

closeOpenTestsAsIncomplete() calls sendTestFrameworkEvent once. The real late finishes go through flushPendingTestFinishEvent(), which retries 3 times with backoff because, per its own SDK-7265 comment, "a dropped send orphans the test". The synthetic close is the last chance a test gets, so it is the send that most needs the retry.

Separately, the loop has no per-item boundary. If instance.updateMultipleEntries throws for one entry, the exception leaves awaitLateTestFinishes() and after() catches it. The remaining open tests are then not closed, and the sends already queued in lateFinishSends are not awaited before teardown. The classic sweepUnfinished isolates each item (insights-handler.ts per-entry try/catch).

Suggested Fix

Reuse the existing retry path, and isolate each entry:

for (const [uuid, args] of this.openTests) {
    try {
        (args.instance as TestFrameworkInstance).updateMultipleEntries({ /* same fields */ })
        this.syntheticallyClosed.add(uuid)
        this.pendingTestFinishes.set(uuid, { args, uuid })
    } catch (err) {
        this.logger.debug(`closeOpenTestsAsIncomplete: ${uuid}: ${util.format(err)}`)
    }
}
this.openTests.clear()
const send = this.flushPendingTestFinishEvent()   // pinned uuid + 3-attempt retry
if (send) { this.trackIn(this.lateFinishSends, send) }

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant