Skip to content

Lite and Darling WPF tests take one gate so parallel STA classes stop racing a shared WPF cache (#5559) - #5567

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/5559-wpf-sta
Oct 8, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
fix/5559-wpf-sta

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Test-only fix for the parallel-STA flake in the Lite and Darling WPF tests.

SECTION: None

Why

Two CI attempts of the same Lite shard failed with InvalidOperationException: Operations that change non-concurrent collections must have exclusive access. The class differed each time. The stack ran through AccessibleNames.ActivateCell and DependencyPropertyDescriptor.AddValueChanged.

The cause is confirmed in the WPF source (dotnet/wpf, WindowsBase). DependencyPropertyDescriptor.FromProperty returns a descriptor out of a process-wide static cache. Every STA thread therefore gets the same DependencyObjectPropertyDescriptor. Its AddValueChanged calls _trackers.TryGetValue(...) and _trackers.Add(...) on a plain instance Dictionary with no lock.

The app has one UI thread, so the product is not exposed. xUnit runs test classes in parallel. 26 Lite.Tests classes and 25 Darling.Tests classes start their own STA thread. Two of them inside ActivateCell at once corrupt that dictionary, and the corruption lasts for the rest of the process.

What changes

  • Darling/Darling.Tests/WpfStaGate.cs is new and is linked into Lite.Tests like the other shared test files. It holds one SemaphoreSlim(1,1), taken with using var staGate = WpfStaGate.Enter();.
  • All 51 STA helpers (26 Lite.Tests files, 25 Darling.Tests files) take the gate on the calling thread, right before they create the thread. They keep it until the join. Two helpers use a timed Join(60s). Taking the gate on the caller means the wait for it is not charged to that timeout. No other line in those files changed.
  • WpfStaGateCensusTests in each project is tagged Stage=Guard. It fails and names any test file with an STA start that has no gate entry before it. It has a floor of 20 STA files, so a broken scan fails too. It also holds a small test that three threads through the gate never overlap.
  • .github/workflows/build.yml lists the new linked file in the lite_linked_shard paths. DarlingReadsTraitGuardTests pins that list to the project's linked compiles.

No guard, assertion, timeout or skip was changed. Some of these classes already carry another [Collection] (server-time-helper, app-logger-statics). A class cannot join a second xUnit collection, which is why this is a gate and not a collection.

Test plan

  • Cause plant (throwaway, not committed). Two STA threads start together on a barrier. Each calls AddValueChanged 20000 times on the shared ContentControl.ContentProperty / DataGridCell descriptor. Ungated, it failed with the CI message in 5 of 6 runs, about 0.25 s each. With a lock around the call it passed 3 of 3. I ran each alone, because a failed run leaves the shared dictionary corrupted for the process.
  • RED at the base content. Stripping the gate lines (26 in Lite, 25 in Darling) makes EveryStaThreadStart_IsPrecededByItsOwnGateEntry fail and name every file, in both projects. With Enter() made a no-op, TheGate_LetsOnlyOneHolderIn_AtATime fails. I restored both and the tree was clean.
  • Lite.Tests, all 30 classes in the 26 STA files plus the census class: Total 547, Failed 0.
  • Darling.Tests, all 35 classes in the 25 STA files plus the census class: Total 299, Failed 0, Skipped 44. The skips are live PostgreSQL classes, and there is no rig here.
  • *Census* and *Ratchet*: Lite.Tests Total 60, Failed 0. Darling.Tests Total 492, Failed 0, Skipped 1 (live).
  • -trait Stage=Guard -failSkips: Lite.Tests Total 443, Failed 0. Darling.Tests Total 1411, Failed 0.
  • 0 build warnings in both projects. The guard run caught one real gap: DarlingReadsTraitGuardTests failed until the new linked file was in the build.yml list.
  • CI shard times before and after. This PR's run (all four Lite shards ran in full): Lite 0-3 took 507, 529, 749 and 681 s. Four earlier full Lite runs on 10-08 took 505-819, 385-493, 626-1032 and 667-1017 s for the same shards, so the gate adds no time that shows above run-to-run noise. The Darling PG shards took 761, 513, 444, 599, 428 and 618 s here against 676-706, 425-571, 317-446, 470-536, 370-536 and 543-581 s on three peer runs the same hour, which is within the same noise.
  • Full suites are left to CI (laptop rule).

Notes for the seat: I passed the wrong project path to my own runner once, and it ran the whole Darling.Tests suite. Two classes failed there: SensitiveStatementAutoParamTests (a timing measure) and DiagnosticsBundleHardeningTests. Both pass alone (Total 80, Failed 0), so I read them as load-sensitive and unrelated. The gate does not cover non-STA test code that touches WPF types from other threads. The census only sees STA thread starts.

Fixes #5559

@erikdarlingdata
erikdarlingdata marked this pull request as ready for review October 8, 2026 13:35
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.

1 participant