Skip to content

fix(test-runner): make junit report delivery resilient on starved CI VMs - #419

Merged
NathanWalker merged 4 commits into
mainfrom
fix/ci-junit-report-delivery
Jul 30, 2026
Merged

fix(test-runner): make junit report delivery resilient on starved CI VMs#419
NathanWalker merged 4 commits into
mainfrom
fix/ci-junit-report-delivery

Conversation

@edusperoni

@edusperoni edusperoni commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Problem

The intermittent CI failure Asynchronous wait failed: exceeded 600 seconds with unfulfilled "Jasmine tests" expectation is not a crash and not a runtime hang mid-suite. Diagnostics from failed runs (e.g. 30567093340) show the Jasmine suite completing normally (~50s of spec activity, crash watchdog never fires), after which the single junit report POST to the Embassy server in the XCUITest host never completes and the app goes silent. Two contributing factors, both addressed here:

  1. The POST was fire-and-forget with an empty completion handler — one lost request and the host inevitably burns its full 600s.
  2. The Embassy accept loop ran on a DispatchQueue.global(qos: .background) thread; Darwin throttles background-QoS threads aggressively (CPU and I/O), which on a loaded 3-core CI VM can starve the one thread the test outcome depends on.

There is also a rarer variant (reproduced locally while validating this PR) where the app's main thread wedges right at suite completion — the POST gets initiated but main-queue callbacks never run again. Retries cannot help that variant; the diagnostics upload below is what makes it debuggable, and it is being investigated separately.

Changes

  • Retry the report POST (TestRunner/app/tests/index.js): up to 10 attempts, 5s apart, 30s per-request timeout. Each failure is logged to the app console so future flakes are diagnosable straight from the xcresult diagnostics.
  • Bound retries by a shared delivery deadline: the host passes REPORT_DEADLINE_SECONDS (wait budget − 60s, counted from app launch); the app stops retrying while there is still room for one final request, so the sentinel always lands before the host times out.
  • Fail fast with a diagnostic: if the retry budget is exhausted, the app POSTs a sentinel to <REPORT_BASEURL>/delivery_failed; the host then fails immediately with could not deliver the junit report: <reason> instead of a generic 600s timeout.
  • Raise the Embassy event loop to .userInitiated QoS (TestRunnerTests/TestRunnerTests.swift).
  • Preserve the first in-step attempt's xcresult (pull_request.yml, npm_release.yml): the retry used to rm -rf the failed attempt's bundle, destroying the evidence of the very failure being retried. It is now renamed to test_results_attempt1.xcresult and included in the test-results artifact.
  • Upload failure diagnostics (new collect-test-diagnostics composite action, used by both workflows on failure()): host-side crash reports (.ips), CoreSimulator logs, and a process-filtered unified-log slice from a still-booted simulator. It deliberately does not boot a device post-mortem or collect a full logarchive — the xcresult's exported diagnostics already carry the app's stdout/stderr and testmanagerd logs.

Duplicate/late POSTs are harmless: over-fulfilling the expectation is already relied upon by the crash watchdog, and an extra junit attachment is benign.

Testing

Full local simulator suite (run_tests.sh) passes with these changes (results delivered through the new retry path end-to-end). Workflow/action YAML validated. One local run reproduced the pre-existing end-of-suite wedge described above — with these changes it produces the same outcome as before (600s timeout), but the new artifacts capture the evidence needed to chase it.

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability when delivering JUnit test reports by retrying failed submissions with timing-aware deadlines.
    • Added a request timeout to prevent report delivery from hanging indefinitely.
    • Delivery failures now surface diagnostic details (including undeliverable reason) more quickly.
    • Improved CI reliability by ensuring the test runtime server isn’t deprioritized during delivery handling.
  • CI Improvements
    • Preserve the first failed .xcresult as an attempt1 artifact instead of discarding it.
    • Collect and upload targeted test diagnostics when the job fails.

The Jasmine suite reports results through a single fire-and-forget POST
to the Embassy server in the XCUITest host. On overloaded CI VMs that
POST can be lost (the server's accept loop runs at .background QoS and
gets starved), so the host waits out its full 600s budget and the job
fails with the generic 'unfulfilled Jasmine tests expectation' error
even though every spec passed. Same-VM step retries then fail the same
way (run 30567093340).

- Retry the report POST up to 10 times with a 30s per-request timeout,
  logging each failure to the app console so future flakes are
  diagnosable from the xcresult diagnostics.
- Run the Embassy event loop at .userInitiated QoS instead of
  .background.
- When the retry budget is exhausted, POST a best-effort sentinel to
  <REPORT_BASEURL>/delivery_failed so the host fails immediately with a
  delivery diagnostic instead of a 600s timeout.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@edusperoni, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ed207301-bce4-40c8-ad4b-8b9593d6a07f

📥 Commits

Reviewing files that changed from the base of the PR and between b1138b7 and f6cc6e0.

📒 Files selected for processing (4)
  • .github/actions/collect-test-diagnostics/action.yml
  • .github/workflows/npm_release.yml
  • .github/workflows/pull_request.yml
  • TestRunner/app/tests/index.js
📝 Walkthrough

Walkthrough

JUnit delivery now uses a launch-relative deadline, bounded retries, and a diagnostic failure callback. The XCTest harness captures delivery reasons, while CI preserves failed test results and uploads simulator and host diagnostics.

Changes

Test delivery and diagnostics

Layer / File(s) Summary
Deadline-aware JUnit delivery
TestRunner/app/tests/index.js
JUnit POST retries are gated by request timing and the launch-based delivery deadline; exhausted delivery reports the actual attempt count.
Harness failure capture and runtime outcome
TestRunnerTests/TestRunnerTests.swift
The server stores /delivery_failed reasons separately, uses user-initiated scheduling, configures the app deadline, and fails completed runs with captured delivery errors.
Failed-attempt preservation and CI diagnostics
.github/actions/collect-test-diagnostics/action.yml, .github/workflows/...
Failed Xcode results are preserved, diagnostics are collected on failure, and primary plus first-attempt result bundles are uploaded.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TestRunner
  participant TestServer
  participant testRuntime
  participant CI
  TestRunner->>TestServer: POST JUnit XML
  TestServer-->>TestRunner: Delivery failure
  TestRunner->>TestRunner: Check deadline and retry budget
  TestRunner->>TestServer: POST /delivery_failed diagnostic
  TestServer->>testRuntime: Store delivery failure reason
  testRuntime-->>testRuntime: Fail completed run with reason
  CI->>CI: Preserve failed xcresult
  CI->>CI: Collect and upload diagnostics
Loading

Suggested reviewers: nathanwalker

Poem

A bunny sends reports through the night,
Hopping with retries till timing is right.
If delivery fails on its final run,
A note tells the harness what wasn’t done.
CI saves the clues till the morning sun.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: making JUnit report delivery more resilient on starved CI VMs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@TestRunner/app/tests/index.js`:
- Around line 29-66: Update the retry flow in attemptReport and its
maxAttempts/retryDelayMs configuration to enforce a shared delivery deadline,
ensuring all report attempts, delays, and the final delivery_failed sentinel
complete within the host’s total wait budget. Preserve the existing retry
diagnostics while stopping retries early enough to reserve time for the
sentinel.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 35a00143-4272-40d3-b1a2-9ab606d49200

📥 Commits

Reviewing files that changed from the base of the PR and between 2f47ad9 and cf3e3d9.

📒 Files selected for processing (2)
  • TestRunner/app/tests/index.js
  • TestRunnerTests/TestRunnerTests.swift

Comment thread TestRunner/app/tests/index.js Outdated
@NathanWalker NathanWalker added this to the 9.1 milestone Jul 30, 2026
The host's 600s wait covers the suite run plus report delivery, so an
unbounded retry ladder (up to ~345s) could still push the delivery_failed
sentinel past the host timeout after a slow suite. The host now passes
REPORT_DEADLINE_SECONDS (wait budget minus a 60s margin, counted from app
launch) and the app stops retrying while there is still room for one
final sentinel request.
The in-step retry used to rm -rf the failed attempt's result bundle, so
the evidence of the very failure being retried never reached the
uploaded artifact. Rename it aside instead and include it in the
test-results upload.

On failure, a new collect-test-diagnostics composite action gathers what
the xcresult does not carry: host-side crash reports (.ips),
CoreSimulator logs, and a process-filtered unified-log slice from a
still-booted simulator. It deliberately never boots a device post-mortem
and never collects a full logarchive - the xcresult's exported
diagnostics already contain the app's stdout/stderr and testmanagerd
logs.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
TestRunnerTests/TestRunnerTests.swift (1)

44-54: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make expectation fulfillment idempotent across retries.

The app can POST once for the initial report, retry up to 9 times, then POST /delivery_failed; each accepted POST currently calls runtimeUnitTestsExpectation.fulfill(). If a posted request is accepted but its response is lost, a later retry or sentinel can call fulfill() again, causing an XCTest API-violation and replacing the captured failure reason. Gate fulfillment with a one-shot flag while still retaining the first report or failure payload.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@TestRunnerTests/TestRunnerTests.swift` around lines 44 - 54, Update the
request-handling flow around runtimeUnitTestsExpectation.fulfill() to guard
fulfillment with a one-shot flag, ensuring retries and the /delivery_failed
sentinel cannot fulfill the expectation more than once. Preserve the existing
behavior of retaining the first accepted report or failure payload, including
reportDeliveryFailureReason, while subsequent accepted POSTs only return their
response.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/actions/collect-test-diagnostics/action.yml:
- Around line 29-31: Update the unified-log collection command in the
diagnostics step to run through a bounded timeout, while preserving any output
produced before the timeout and the existing non-failing behavior. Keep the
current simulator predicate, output file, and stderr handling intact.

In `@TestRunner/app/tests/index.js`:
- Around line 4-5: Recheck the REPORT_DEADLINE_SECONDS budget when each attempt
actually begins, including the setTimeout callback and the initial
attemptReport(1) entry. In attemptReport, if Date.now() has reached deadlineMs,
send the existing sentinel directly and do not start another POST; preserve the
retry flow only when remaining time is available.

---

Outside diff comments:
In `@TestRunnerTests/TestRunnerTests.swift`:
- Around line 44-54: Update the request-handling flow around
runtimeUnitTestsExpectation.fulfill() to guard fulfillment with a one-shot flag,
ensuring retries and the /delivery_failed sentinel cannot fulfill the
expectation more than once. Preserve the existing behavior of retaining the
first accepted report or failure payload, including reportDeliveryFailureReason,
while subsequent accepted POSTs only return their response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: af91cdf2-62ed-49ba-a843-b44fe4a1660e

📥 Commits

Reviewing files that changed from the base of the PR and between cf3e3d9 and b1138b7.

📒 Files selected for processing (5)
  • .github/actions/collect-test-diagnostics/action.yml
  • .github/workflows/npm_release.yml
  • .github/workflows/pull_request.yml
  • TestRunner/app/tests/index.js
  • TestRunnerTests/TestRunnerTests.swift

Comment thread .github/actions/collect-test-diagnostics/action.yml Outdated
Comment thread TestRunner/app/tests/index.js
…ostics

The first real-world run of the diagnostics path exposed three holes:

- The collect step runs under bash -e -o pipefail, so the UDID lookup
  aborted the whole script when no device was booted (grep exit 1
  propagates through the command substitution into the assignment).
- xcodebuild leaves an extensionless staging path at -resultBundlePath
  when it dies early (e.g. 'Unable to find a device'), and the retry then
  fails with 'Existing file at -resultBundlePath'. The retry cleanup now
  removes everything at the result path except the preserved
  test_results_attempt1.xcresult.
- simctl blocks indefinitely when CoreSimulatorService is wedged - the
  very state the diagnostics step tends to run in - so log show now runs
  under a 120s watchdog.

Also re-check the report retry deadline when the timer fires rather than
when it is scheduled: under load timers run late, and a retry started
past the window would consume the delivery_failed sentinel's slot.
@NathanWalker
NathanWalker merged commit e5e84e0 into main Jul 30, 2026
9 checks passed
@NathanWalker
NathanWalker deleted the fix/ci-junit-report-delivery branch July 30, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants