Skip to content

fix(tests): Time out a hung test instead of the whole shard - #24472

Open
MartinZikmund wants to merge 1 commit into
masterfrom
dev/mazi/runtime-test-default-timeout
Open

MartinZikmund wants to merge 1 commit into
masterfrom
dev/mazi/runtime-test-default-timeout

Conversation

@MartinZikmund

Copy link
Copy Markdown
Member

GitHub Issue: closes #24471

PR Type:

🐞 Bugfix

What changed? 🚀

UnitTestsControl only bounded a test body when the method carried an explicit [Timeout]; the else branch was a bare await task. So one hung async test with no attribute was awaited forever, the results XML was never written, and the job died on the harness or job timeout instead — discarding the whole shard, including up to ~1750 results that had already passed and the name of the test that hung.

That makes it an amplifier: three unrelated WebAssembly failure mechanisms (the sample runner never registering, a mid-run hang, and heap exhaustion) all collapse into the same undiagnosable "no results file" symptom. It is also why failures inside timed-out jobs never reach any flakiness analysis — they contribute zero rows.

[Timeout] now overrides a default instead of being the only source of one. The single timeout path replaces the if/else, and the timer is cancelled once the test completes so a long default doesn't leave one pending per test for the rest of the run.

Choosing the default

The existing DefaultUnitTestTimeout (60 s in Release, applied only to initialize/cleanup) was the obvious thing to reuse — and it would have broken CI. Measured per-test durations on healthy agents:

platform tests timed p50 p99 max
Android Skia 4736 0.1 s 1.2–2.6 s 34.1 s (When_Polyline)
WebAssembly Skia 6039 0.03 s ~3 s 76.5 s (When_Line)

The shape-parity tests legitimately reach ~77 s on WebAssembly, so a 60 s default would have turned currently-green builds red. 5 minutes is ~4× the observed healthy maximum, which is ample for its actual job — converting an infinite hang into one named failure — while staying well inside the shard budget.

Durations were recovered from the per-test Running test X() lines in the published Android device logs and the WebAssembly job logs of build 232676.

Known limitation

On single-threaded WebAssembly, a Task.Delay continuation cannot run if a test blocks the dispatcher synchronously. This fix covers async hangs, which is the common case; a synchronous block still needs a JS-side watchdog. Not addressed here.

Validation

  • Compile: UnoIslandsSamplesApp.Skia (which includes SamplesApp.UnitTests.Shared) builds with 0 errors on net10.0.
  • Code review: GetTestTimeout still returns the [Timeout] value when present, so existing per-test timeouts are unchanged; only the previously-unbounded path gains a ceiling.
  • Not yet exercised against a real hang in CI — the behaviour change is only observable when a test hangs, which is what this PR's CI run cannot deliberately produce.

PR Checklist ✅

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jox2uQ5z5SLRqfrhGMg2ZX

Only tests carrying an explicit [Timeout] were bounded; every other body
was awaited forever, so one hang discarded the shard's entire results
file - including the ~1750 tests that had already passed and the name of
the one that hung.

Default to 5 minutes, well above the ~77s the shape parity tests need on
WebAssembly, and cancel the timer once the test completes so the default
does not leave one pending per test.

fixes #24471

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jox2uQ5z5SLRqfrhGMg2ZX
Copilot AI lite review requested due to automatic review settings September 9, 2026 15:19
@github-actions github-actions Bot added the area/automation Categorizes an issue or PR as relevant to project automation label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 3m 3s —— View job


Review complete ✅

Scope: src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UnitTest/UnitTestsControl.cs (+20/-16), the single changed file.

  • Read PR description and diff (git diff origin/master...HEAD)
  • Read full surrounding context (timeout call site, GetTestTimeout, the sibling WaitResult/DefaultUnitTestTimeout path, the catch/finally around test execution)
  • Checked async/cancellation discipline, root-cause vs. guard classification, test coverage, style
  • Posted inline findings

Verdict: fix-first

2 inline findings posted, both medium/low severity — no blockers to the core fix.

Cross-cutting notes (not tied to a single line):

  • The core mechanism is sound and correctly root-cause (replaces the unbounded else { await task; } branch rather than adding a guard around it), and the timeoutCancellation.Cancel() call correctly avoids leaving a live 5-minute Task.Delay timer per test for the rest of the run — good attention to that failure mode.
  • The one substantive gap (see inline comment on line 68) is that the new 5-minute ceiling applies uniformly to DEBUG and Release, unlike the pre-existing DefaultUnitTestTimeout a few lines above it, which is deliberately split (300s DEBUG / 60s Release) specifically to tolerate breakpoint pauses during local debugging. As written, a developer stepping through a runtime test for >5 minutes will now get a spurious TimeoutException where the old code just waited.
  • The second finding (line 1021) is a minor test-coverage gap consistent with the repo's red/fix/green guidance for bug fixes; the PR body's justification for not adding one (hard to produce a deterministic hang in CI) is reasonable, but a narrow unit test on GetTestTimeout(test) ?? DefaultTestMethodTimeout precedence would be cheap insurance.
  • Everything else checked out: no .Result/.GetAwaiter().GetResult(), no new event Action, braces/tabs/style consistent with the surrounding file, no platform-suffix or Generated/ concerns (this file isn't platform-partitioned), and the exception-rethrow path (await resultingTask after a faulted task wins the race) is unchanged from the prior behavior.

// Ceiling for a test body that declares no [Timeout]. Deliberately far above any real test
// — the shape parity tests measure ~77s on WebAssembly — because its only job is to turn a
// hung test into one named failure rather than a whole shard whose results are never written.
private static readonly TimeSpan DefaultTestMethodTimeout = TimeSpan.FromMinutes(5);

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.

DefaultTestMethodTimeout is a fixed 5 minutes for every build configuration, but the sibling DefaultUnitTestTimeout right above it (lines 59-63) is deliberately DEBUG-vs-Release split (300s / 60s) — presumably because a breakpoint held during local debugging can legitimately pause a test body for several minutes, and the old code let untagged tests await forever so that never mattered.

With this change, any untagged async test now gets a hard 5-minute ceiling in DEBUG too. A developer stepping through a runtime test with a debugger for >5 minutes will now hit a spurious TimeoutException where previously it just waited — a regression in the local dev-loop that the CI-focused p50/p99 measurements in the PR description don't cover (those numbers come from unattended CI runs).

Consider gating this like DefaultUnitTestTimeout (e.g. skip/extend the default when Debugger.IsAttached, or reuse the DEBUG/Release split) so the CI robustness fix doesn't trade away the "sit at a breakpoint as long as you want" debugging experience.

Fix this →

if (timeout.HasValue)
{
var timeoutTask = Task.Delay(timeout.Value);
var timeout = GetTestTimeout(test) ?? DefaultTestMethodTimeout;

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.

No test exercises the new default-timeout path itself (a [Timeout]-less test that hangs → reported as a named TimeoutException instead of stalling the run). The PR body explains this is hard to produce deterministically in CI, which is fair, but per the repo's red/fix/green guidance for bug fixes it's worth at least a narrow unit test around GetTestTimeout(test) ?? DefaultTestMethodTimeout (e.g. a fake UnitTestMethodInfo with/without [Timeout]) to lock in the "explicit attribute overrides the default" contract this line encodes, since that's the one thing this fix must never regress.

Copilot AI left a comment

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.

🟢 Approval recommended

The logic change is straightforward and appears correct for preventing indefinite awaits, with only a minor optional simplification/perf tweak noted in review comments.

Pull request overview

This PR updates the SamplesApp unit test harness so a hung async test (with no [Timeout]) fails as a named timeout instead of hanging the entire shard and preventing result publication.

Changes:

  • Introduces a 5-minute default timeout ceiling for test bodies that don’t declare [Timeout].
  • Replaces the previous “timeout only if attribute exists” branching with a single timeout path for Task-returning tests.
  • Cancels the timeout timer once the test completes to avoid accumulating pending timers across large runs.
File summaries
File Description
src/SamplesApp/SamplesApp.UnitTests.Shared/Controls/UnitTest/UnitTestsControl.cs Adds a default test-body timeout and applies it uniformly to async tests to avoid shard-wide hangs and missing results.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +1023 to +1040
using var timeoutCancellation = new CancellationTokenSource();
var timeoutTask = Task.Delay(timeout, timeoutCancellation.Token);

if (resultingTask == timeoutTask)
{
throw new TimeoutException(
$"Test execution timed out after {timeout.Value}");
}
var resultingTask = await Task.WhenAny(task, timeoutTask);

// Rethrow exception if failed OR task cancelled if task **internally** raised
// a TaskCancelledException (we don't provide any cancellation token).
await resultingTask;
}
else
if (resultingTask == timeoutTask)
{
await task;
throw new TimeoutException(
$"Test execution timed out after {timeout}");
}

// Release the timer, so the default timeout does not leave one pending
// per test for the remainder of the run.
timeoutCancellation.Cancel();

// Rethrow exception if failed OR task cancelled if task **internally** raised
// a TaskCancelledException (we don't provide any cancellation token).
await resultingTask;
@unodevops

Copy link
Copy Markdown
Contributor

⚠️⚠️ The build 232785 has failed on Uno.UI - CI.

@morning4coffe-dev morning4coffe-dev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed e13b6388e2ee, including the surrounding retry, exception-reporting and cleanup paths.

I exercised the exact selected await blocks from base and head, the unchanged GetTestTimeout logic, and the actual five-minute default in an isolated .NET probe. Seven checks passed: explicit attribute precedence; the unannotated base control remaining hung; completed tasks cancelling their timers; preservation of the original exception; preservation of task cancellation; an explicit 75 ms timeout; and the real default timeout against a genuinely incomplete task. The default fired after 300.061 seconds with the expected TimeoutException, while the base control was still incomplete. The default was not shortened or replaced with a fake timer.

The timeout exception still flows through the existing named-test failure/retry path. Cancelling the losing timer before awaiting the winning task preserves fault and cancellation behavior rather than turning either into a success.

The existing debugger-duration note is a useful non-blocking follow-up: unannotated Debug tests now also have the deliberate five-minute ceiling. This change does not interrupt synchronous blocking or cancel arbitrary underlying test work; the outer harness watchdog remains necessary.

Validation limits: the selected source logic used minimal test-metadata stand-ins. This was not a full SamplesApp, native WinUI, or WebAssembly runtime run, nor a newly executed CI hang. Other reviewers' threads were left unchanged.

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

Labels

area/automation Categorizes an issue or PR as relevant to project automation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] A hung runtime test with no [Timeout] discards the whole shard's results

4 participants