Skip to content

Add lifetime stress test harness + scheduled soak pipeline - #11830

Open
Protik Biswas (protikbiswas100) wants to merge 29 commits into
mainfrom
user/protikbiswas/lifetime-stress-tests
Open

Protik Biswas (protikbiswas100) wants to merge 29 commits into
mainfrom
user/protikbiswas/lifetime-stress-tests

Conversation

@protikbiswas100

@protikbiswas100 Protik Biswas (protikbiswas100) commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces dedicated object-lifetime test coverage - Lifetime crashes (use-after-free, premature native peer destruction, ref-counting mistakes in the 3-layer peer model) are recurring and are never fixed in just one place so we need a standing harness that keeps catching them.

The suite is report-only: it never gates a PR. It surfaces lifetime bugs as a report from a scheduled soak pipeline instead of blocking the shared Run Tests stage (see How it plugs into CI for why a native crash cannot be downgraded to a warning).

Fixes

https://task.ms/64008072

Files changed

File Type What
controls/test/MUXControlsTestApp/LifetimeStressTests.cs new The lifetime stress harness + scenarios.
build/WinUI-LifetimeStress.yml new Scheduled soak pipeline.
docs/testing/lifetime-stress-tests.md new Rationale, CI behavior, usage.
Helix/common/test/RunHelixWorkItem.ps1 modified Scale the per-test TAEF /testtimeout with the soak budget when WINUI_LIFETIME_STRESS_MINUTES > 0 (otherwise unchanged at 5 min).

What's added

  • controls/test/MUXControlsTestApp/LifetimeStressTests.cs — a lifetime stress harness modeled on the existing LeakTests.cs. It repeatedly creates → parents → lays out → unparents → drops → collects across:

    • a broad create/load/unload sweep of controls,
    • element reparenting (enter/leave / peer re-association),
    • window open/close,
    • ListView container recycling,
    • Popup open/close,
    • NavigationView menu churn,
    • TabView add/remove,
    • a dedicated ItemsRepeater realization/recycling scenario — currently quarantined ([TestProperty("Ignore", "True")]) because it reproduces a deterministic native crash (0xC000027B stowed exception in combase.dll); re-enable once that underlying ItemsRepeater lifetime bug is understood/fixed.

    How it "does better" than the old for-hours soak: every iteration aggressively drains the UI thread and forces GC.Collect + WaitForPendingFinalizers + GC.Collect, so a dangling native peer faults promptly (on the iteration that created it) instead of eventually.

  • build/WinUI-LifetimeStress.yml — a scheduled pipeline that builds WinUI and runs the suite in soak mode by setting WINUI_LIFETIME_STRESS_MINUTES.

  • docs/testing/lifetime-stress-tests.md — rationale, CI behavior, and usage.

How it plugs into CI

The class is tagged [TestProperty("TestSuite", "LifetimeStressTestSuite")] and [TestProperty("Classification", "Integration")], so the existing Helix work-item generator (Helix/common/pipeline/GenerateHelixWorkItems.ps1) emits an isolated work item for the suite on every DevTestSuite pass — a lifetime crash is contained to its own work item instead of cascading into unrelated tests.

Report-only — the suite never fails a PR. A real object-lifetime bug faults as a native crash / fail-fast (e.g. a stowed exception in combase.dll) that terminates the TAEF test host. Managed code cannot catch a native fail-fast and downgrade it to a warning, so running the workload in the PR gate could fail the Run Tests stage and block unrelated PRs. To prevent that, the scenarios do their actual create/teardown/GC work only when explicitly asked:

  • Scheduled soakbuild/WinUI-LifetimeStress.yml sets WINUI_LIFETIME_STRESS_MINUTES > 0; each scenario loops on a wall-clock budget. This is the normal place the suite exercises anything.
  • Explicit local/manual run — set WINUI_LIFETIME_STRESS_ITERATIONS > 0 for a fixed number of cycles.

In the PR gate and Nightly — where neither variable is set — every scenario skips (logs a report line and returns). The suite is still built, discovered, and reported, but does no work and therefore cannot crash, throw, or hang the gate. Within a run that does execute, a residual (uncollected) reference is a soft signal: logged as a Log.Warning, not a failed result.

Configuration

Read from the environment. When neither is set (the default, including the PR gate and Nightly), scenarios skip — so the suite is safe everywhere by default.

Variable Meaning Default
WINUI_LIFETIME_STRESS_MINUTES If > 0, each scenario soaks for this many minutes (wall-clock). The scheduled soak pipeline sets this; the normal way the suite does work. 0 (disabled)
WINUI_LIFETIME_STRESS_ITERATIONS If > 0 and soak mode is off, run this many create/destroy cycles per scenario — for explicit local/manual runs. 0 (skip)

Reintroduces the object-lifetime coverage XAML lost when the old Win8-era
lifetime tests were dropped. Adds an isolated TAEF suite that repeatedly
creates/loads/unloads/reparents controls (plus a dedicated ItemsRepeater
realization/recycling scenario) while aggressively forcing GC + finalizers
each iteration, so dangling native peers fault promptly instead of eventually.

- LifetimeStressTests.cs: gate by default (auto-runs as an isolated Helix work
  item on every DevTestSuite pass); soak-capable via WINUI_LIFETIME_STRESS_*
  environment variables.
- WinUI-LifetimeStress.yml: scheduled pipeline that builds and runs the suite
  in soak mode.
- docs/testing/lifetime-stress-tests.md: rationale, CI behavior, usage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@microsoft-github-policy-service microsoft-github-policy-service Bot added the needs-triage Issue needs to be triaged by the area owners label Sep 9, 2026
…gate

The LifetimeStressTests class was already being picked up by the WinUI-GitHub-PR
(DevTestSuite) test pass implicitly: MUXControlsTestApp sets Classification=Integration
module-wide via ApiTestAssemblyHandling.AssemblyInitialize, and Helix\GenerateHelixWorkItems.ps1
emits a dedicated per-TestSuite work item ("*-LifetimeStressTestSuite") into
RunTestsInHelix-MUXControlsApiTests.proj, which the run-test job executes without any
suite filter.

Declare [TestProperty("Classification", "Integration")] on the class explicitly so that
participation in the per-PR gate is self-documenting and robust to future changes in the
module-level defaults, matching the convention used by the InteractionTests classes. The
value is identical to the inherited module-level property, so there is no behavior change
beyond making the intent explicit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b2bb4a27-c21b-4b24-8fcd-e67911217778
Comment thread controls/test/MUXControlsTestApp/LifetimeStressTests.cs Outdated
The StressControlCreateLoadUnloadCollect scenario used failOnLeak:true, which emitted a failed test result on any residual reference. That failed the Run Tests stage's Publish Test Results step and blocked the whole pipeline on a soft/flaky leak signal. Log leaks as warnings instead so the suite reports lifetime concerns without gating; a real lifetime crash still fails its isolated work item.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ort)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses review feedback to cover all controls in StressControlCreateLoadUnloadCollect. Expands CreateControlSet from a small sample to essentially every Microsoft.UI.Xaml.Controls control that is cheaply constructible without a live window/parent/service (excluding WebView2, MapControl, InkToolbar and flyout-only types, which need special hosting; ItemsRepeater has its own scenario).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This scenario crashes the TAEF test host (TE.ProcessHost.exe) with a stowed
exception (0xC000027B) in combase.dll during the realize/recycle churn, which
surfaces as an RPC failure in the Run Tests stage and unconditionally fails the
PR pipeline. A host crash cannot be downgraded to a warning the way a
WeakReference leak can (the process fail-fasts before any managed result is
reported), so mark it [TestProperty("Ignore","True")] following the repo's
existing quarantine convention. Re-enable once the underlying ItemsRepeater
native-peer lifetime bug it exposes is root-caused and fixed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread build/WinUI-LifetimeStress.yml
Comment thread controls/test/MUXControlsTestApp/LifetimeStressTests.cs Outdated
…rage

Runner timeout (addresses review feedback):
In soak mode each lifetime scenario loops on a wall-clock budget
(WINUI_LIFETIME_STRESS_MINUTES, default 10 min) but RunHelixWorkItem.ps1
hardcoded a 5-minute per-test TAEF timeout (/testtimeout:0:05), so TAEF killed
every soak scenario as a "hang" before it finished - raising only the outer job
timeout cannot fix this. Make the per-test timeout scale with the soak budget
(budget + headroom) when WINUI_LIFETIME_STRESS_MINUTES > 0, and keep the
original 5-minute default for all other test passes.

Broader coverage:
Lifetime bugs live across WinUI, not just ItemsRepeater. Add stress scenarios
for other high-risk subsystems, all using supported high-level APIs and
self-contained hosting:
  * StressListViewContainerRecycling - ListView/GridView virtualization
    (container generation/recycling via ModernCollectionBasePanel).
  * StressPopupOpenClose - Popup open/close (popup root/overlay + hosted peer).
  * StressNavigationViewMenuChurn - NavigationView menu-item/pane/selection churn.
  * StressTabViewAddRemove - TabView tab container + content add/remove.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The suite could still fail the PR pipeline: only a detected leak was
non-gating (Log.Warning). A native crash / fail-fast (e.g. the
ItemsRepeater stowed exception in combase.dll), an unhandled managed
exception, or a hang would terminate the TAEF host and fail the gate,
because managed code cannot catch a native fail-fast and downgrade it.

Make the workload report-only by running it in only two situations:
  * the scheduled soak pipeline (WinUI-LifetimeStress.yml), the one
    place that sets WINUI_LIFETIME_STRESS_MINUTES > 0; and
  * an explicit local opt-in via WINUI_LIFETIME_STRESS_ITERATIONS > 0.
In the per-PR gate and Nightly (neither variable set) every scenario
skips at the RunStress choke point, so the suite is still built,
discovered and reported but does no create/teardown/GC work and cannot
crash, throw or hang the gate. Classification=Integration is retained so
the Helix generator still emits the dedicated LifetimeStressTestSuite
work item the soak pipeline consumes. Docs/comments updated to match.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The suite no longer runs as a gate on every test pass. Reflect that the
workload runs only in the scheduled soak (WINUI_LIFETIME_STRESS_MINUTES)
or an explicit local run (WINUI_LIFETIME_STRESS_ITERATIONS), skips in the
PR gate and Nightly, and that ItemsRepeater is currently quarantined.
Also add the new scenarios (ListView, Popup, NavigationView, TabView).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously the suite skipped entirely outside the scheduled soak, so no
lifetime report was produced in PR runs. Make it run a small report pass
on every test pass (PR gate + Nightly) while guaranteeing it can never
fail the pipeline for anything a managed catch can reach:

  * RunStress now runs DefaultReportIterations cycles by default (no env
    var needed); WINUI_LIFETIME_STRESS_MINUTES still selects soak and
    WINUI_LIFETIME_STRESS_ITERATIONS an explicit heavier run.
  * New SafeUI wrapper catches exceptions INSIDE the UI-thread callback,
    before RunOnUIThread.Execute can convert them into a Verify.Fail
    (which records a Failed verdict a test-thread catch cannot undo). All
    16 scenario UI calls now route through SafeUI.
  * RunIterationReporting catches test-thread exceptions; leaks remain
    Log.Warning via VerifyCollected(failOnLeak:false). Net: the suite
    never records a Failed result, so Publish Test Results never fails.

The only uncatchable case is a genuine native crash / fail-fast that
kills the TAEF host - the real lifetime signal we want - and the one
known deterministic crasher stays quarantined ([TestProperty Ignore]).
Docs updated to match.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The lifetime stress suite intentionally provokes native peer-lifetime
faults, so a run can end in a fail-fast that crashes the TAEF host
(te.exe) mid-suite. Two vectors let that break the Run Tests stage:

  1. On a native crash te.wtl is never flushed, so no testResults.xml is
     produced -> RunTestPassSliceOnBuildAgent.ps1 throws ("Expected
     testResults.xml ...") and the whole slice fails / the suite vanishes.
  2. A scenario that reports Fail lands as result="Fail" in
     testResults.xml -> PublishTestResults@2 (failTaskOnFailedTests:true)
     fails the stage.

Handle both in RunHelixWorkItem.ps1, scoped strictly to the lifetime
work item (detected via the LifetimeStressTestSuite TaefQuery) so no
other suite is affected:

  * Skip the failed-test reruns (pointless for a stress suite and they
    only add time and extra crash dumps).
  * Tolerate a missing te.wtl instead of erroring.
  * Guarantee a valid, zero-failure testResults.xml via a new
    Set-LifetimeResultsNonGating helper: downgrade any Fail results to a
    non-gating Skip (keeping the per-scenario detail), or emit a single
    synthetic passing "report" entry when te.exe crashed and produced no
    results file.

The full per-scenario report stays readable in the work item's console
log and in te_original.wtl; the suite is always present in the run and
never fails the pipeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 589f8ff8-5e78-4bcc-8075-4a4b817d2144
@protikbiswas100 Protik Biswas (protikbiswas100) changed the title Add lifetime stress test harness (reintroduce Win8-era lifetime tests) + scheduled soak pipeline Add lifetime stress test harness + scheduled soak pipeline Sep 11, 2026
When an upstream CreateTestPayload leg is canceled/hung and never publishes its payload artifact, the RunTests job still ran (condition: not(failed())) and failed later with a cryptic 'Get-ChildItem: Cannot find path ...helixworkitems... PathNotFound' in RunTestPassSliceOnBuildAgent.ps1.

- Require succeeded() on the RunTestPass job so it skips instead of running without a payload.

- Fail fast with a clear, actionable message if the work item proj dir is missing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extends the lifetime stress suite with 9 component-targeted scenarios that drive create/use/teardown/GC for areas that recur in Watson 'Lifetime Issues' buckets: MenuFlyoutPresenter, ResourceDictionary, ItemsSourceView, automation peers (CUIAWindow/AppBar/DependencyObjectPropertyAccess), Frame NavigationCache, XamlReader/XBF, SimpleProperty, MediaTransportControls, and InkToolbar. All remain report-only (non-gating), consistent with the suite design.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…fields

The existing *Native gating scenarios exercised NavigationView/Slider/ComboBox/
ScrollView/TextBlock, none of which touch the cross-boundary peer fields that were
converted from raw ctl::ComPtr to TrackerPtr (ListViewBase::m_spContainerBeingClicked,
ModernCollectionBasePanel layout-strategy/data-info-provider, the SplitView light-
dismiss layer, ToggleSwitch knob/curtain transforms). The controls that do own those
fields were only covered by non-gating report-only scenarios, so the suite could not
actually surface (or validate a fix for) that native lifetime crash class.

Add four gating native scenarios routed through RunNativeStress that specifically
churn those fields and then drive the FINAL native release off the UI thread
(GC + WaitForPendingFinalizers) - the cross-boundary/off-thread release path where a
raw-ComPtr field faults and a TrackerPtr field does not:
  - StressListViewClickContainerChurnNative  (click container + virtualizing panel)
  - StressGridViewContainerChurnNative        (ListViewBase + backing panel)
  - StressSplitViewLightDismissChurnNative    (dismiss-layer popup + dismiss elements)
  - StressToggleSwitchTransformChurnNative    (knob/curtain transform peers)

Aggressive counts run only when AggressiveNativeReproEnabled (soak / opt-in gate); the
light per-PR pass does a small benign churn and cannot crash the shared pipeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@protikbiswas100

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

…ggressive repro

In the $isLifetimeStress path of RunHelixWorkItem.ps1, detect a native TAEF-host
crash (te.exe non-zero exit / new .dmp / a scenario that started but never completed),
attribute it to the in-flight scenario from the "[LifetimeStress] NATIVE: scenario 'X'
starting/completed" markers, and emit a non-gating
##vso[task.logissue type=warning]Native lifetime crash in scenario 'X' (dump: <name>)
before the results are laundered green. This gives attribution without opening the dump
and keeps the stage green.

Name each new dump LifetimeStress-<scenario>-*.dmp and, in RunTestPassSliceOnBuildAgent.ps1,
raise the per-slice dump upload cap (3 -> 10) and prioritize the attributed lifetime dumps
so a native crash dump is never crowded out by unrelated dumps in the same slice.

Also set WINUI_LIFETIME_STRESS_NATIVE=1 for the lifetime work item (unless a pipeline
already provided a value) so the aggressive native-repro variant actually runs in te.exe's
process env; pipeline-root variables do not propagate into the test host on their own.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9b81a104-f576-4af1-972e-c3e04538186d
@protikbiswas100

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

….processhost inherits it

The aggressive native-repro flag was set only at Process scope on the launcher
shell, which never reached the managed harness: the unpackaged test runs in
te.processhost.exe, spawned by TAEF via a broker whose environment block is
seeded from the User/Machine registry environment at creation rather than
inherited from this shell (build 157605025 proved launcher=1 but harness read
aggressiveNativeRepro=False for all 11 native scenarios). Write the value into
the User (and best-effort Machine) registry environment before launching te so
the broker-spawned te.processhost picks it up at creation; keep the Process-scope
set for the direct-child/local-dev case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9b81a104-f576-4af1-972e-c3e04538186d
@protikbiswas100

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

The *Native scenarios previously pre-quiesced the converted cross-boundary peer
fields (nulled Pane/Content/ItemsSource/Child, closed panes, unsubscribed
handlers) BEFORE dropping the reference, so the off-thread finalize released an
already-clean peer - a managed churn + leak check that could not reproduce a
native use-after-free (e.g. StressSplitViewLightDismissChurnNative).

Each scenario now creates a genuine, control-specific dangerous condition:
  * Stop pre-quiescing - keep the converted field populated up to unparent so
    the final native release must tear down a live field off the UI thread.
  * Reentrant teardown - mutate the converted field from inside a lifecycle
    event (Unloaded/Toggled/SizeChanged/Loaded) while the native peer is
    mid-unlink, guarded against loops.
  * After-teardown access - touch a just-unlinked peer/container/leaf.
  * Off-thread final release - via shared FinalizeOffThread().

Balanced (compiles-clean bracket check); behavior stays non-gating via SafeUI /
RunNativeStress (managed throws downgraded to warnings), aggressive variant
scales intensity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9b81a104-f576-4af1-972e-c3e04538186d
@protikbiswas100

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

Each lifetime work item now writes a per-work-item LifetimeNativeCrashReport.json
(native host crashes + non-gating native scenario warnings). A new PostTestRun
step runs Report-LifetimeNativeCrashTotals.ps1 after the test run to total those
records across the shard, print a single count, emit a non-gating warning, publish
the LifetimeNativeCrashTotal variable, and write LifetimeNativeCrashSummary.json.
Fail-open and non-gating.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9b81a104-f576-4af1-972e-c3e04538186d
@protikbiswas100

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

Extend the lifetime-stress managed leak coverage to the container-realizing
and popup controls in CreateControlSet() that had no dedicated churn scenario
(RadioButtons, MenuBar, DropDownButton, SplitButton, InfoBar, TeachingTip).

Adds a shared RunChurnScenario<TControl> harness that mirrors the existing
hand-written churners' leak-probe shape (build a container, churn it while
parented to fill the selection model / realized-container / popup cache, track
a child object with a WeakReference, verify it collects). All new scenarios
inherit the non-gating RunStress/SafeUI/VerifyCollected plumbing, so a residual
reference is reported as a warning and never gates the pipeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9b81a104-f576-4af1-972e-c3e04538186d
@protikbiswas100

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

…nfigs

The PostTestRun aggregation ran in every test-pass job (one per testOS x
buildFlavor) and wrote a workItemCount=0 LifetimeNativeCrashSummary.json in
every job that ran no lifetime work item. The LifetimeStressTestSuite runs
ONLY in the checked (chk) flavor (lifetime/TrackerHandle leak detection needs
the reference-tracker instrumentation that free builds lack), so the fre-flavor
jobs produced a scatter of empty summaries that look like the aggregation is
broken (e.g. searchRoot 'c:\uploadroot\Win11-23H2\x64fre', workItemCount 0).

Report-LifetimeNativeCrashTotals.ps1 now returns early when no per-work-item
LifetimeNativeCrashReport.json is present: it still totals zero and sets the
LifetimeNativeCrashTotal variable, but does not write an empty summary. The
only LifetimeNativeCrashSummary.json in the artifacts is now the populated one
from the chk job that actually ran the suite. Doc updated to match.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9b81a104-f576-4af1-972e-c3e04538186d
@protikbiswas100

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

Condense comments to at most 1-2 lines across the lifetime stress
harness, Helix scripts, and pipeline YAML. Comment-only changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@protikbiswas100

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

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

Labels

needs-triage Issue needs to be triaged by the area owners

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants