UWP secondary view leaks its entire XAML tree on .NET 10: no GC occurs while the view's apartment is alive
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### Description
On .NET 10 with UWP XAML (`UseUwp=true`), closing a secondary `CoreApplicationView` leaves that view's entire XAML tree alive for the life of the process. The managed objects have **no managed root** — in a heap dump of a production app, the leaked page had `com=0, tracker=2` on its CCW and `!gcroot` returned nothing. They are held only by the reference-tracker references XAML takes on their CCWs, and nothing ever resolves them.
The cause appears to be timing rather than a missing callback: **no garbage collection occurs at any point while the closing view's apartment is still alive**, so the reference-tracker walk that resolves XAML↔managed cycles never runs while it still can.
`IReferenceTrackerHost::NotifyEndOfReferenceTrackingOnThread` *does* fire, and `TrackerObjectManager.ReleaseExternalObjectsFromCurrentThread` *does* run — the repro logs what it hands to `ComWrappers.ReleaseObjects`, and the leaked page is among those objects. **Nothing is released for it, though**: CsWinRT's implementation skips every object it cannot unwrap, and `ComWrappersSupport.TryUnwrapObject` returns `false` for an aggregated object — a managed `Page` subclass, i.e. every page in a XAML app.
```csharp
// CsWinRT, DefaultComWrappers
protected override void ReleaseObjects(IEnumerable objects)
{
foreach (var obj in objects)
{
if (ComWrappersSupport.TryUnwrapObject(obj, out var objRef)) // false for BlankPage1
{
objRef.Dispose();
}
}
}
```
So the apartment-teardown path enumerates the object that leaks and then does nothing with it. This is the direct continuation of #114043: that code used to *throw* on aggregated objects and now *skips* them, and neither releases anything.
Holding the closing view's `DispatcherQueue.ShutdownStarting` deferral across a **single** collection releases everything.
### Reproduction Steps
Repro attached, download [SpikeGC.zip](https://github.com/user-attachments/files/32184141/SpikeGC.zip), open in Visual Studio, F5 (x64, Debug). It runs the whole experiment on launch and writes the result to the window and to `LocalState\spike.log`. What it does:
1. UWP app, `net10.0-windows10.0.26100.0`, `UseUwp=true`.
2. `CoreApplication.CreateNewView()`, set `Window.Current.Content = new BlankPage1()`, `Activate()`, `ApplicationViewSwitcher.TryShowAsStandaloneAsync(id)`. Keep a `WeakReference` to the page.
3. From the view's thread, `ApplicationView.GetForCurrentView().TryConsolidateAsync()`, then on `Consolidated` set `Window.Current.Content = null` and `Window.Current.Close()`.
4. `GC.Collect()` / `GC.WaitForPendingFinalizers()` / `GC.Collect()`, then check the `WeakReference`.
The repro runs several rounds, identical except for what happens while the closing view's `DispatcherQueue.ShutdownStarting` deferral is held.
### Expected behavior
The page is collected once the view is closed, as it is when the same application targets .NET Native.
### Actual behavior
Round A (plain close): never collected. Round B (deferral held across one collection): collected.
Collection counts are stamped on every line as `[gen0/gen1/gen2]`:
```
[11:18:40.124][t5][g0/0/0] view created, hold=False
[11:18:42.018][t5][g0/0/0] consolidated
[11:18:42.034][t5][g0/0/0] window closed
[11:18:42.082][t5][g0/0/0] BlankPage1 <- among the objects passed to ReleaseObjects
[11:18:42.083][t5][g0/0/0] !! ReleaseObjects: 6 object(s)
[11:18:43.981][t4][g0/0/0] A plain close: before GC, alive=True
[11:18:44.500][t4][g2/2/2] A plain close: after GC 1, alive=True
[11:18:45.006][t4][g4/4/4] A plain close: after GC 2, alive=True
[11:18:45.519][t4][g6/6/6] A plain close: after GC 3, alive=True
[11:18:45.519][t4][g6/6/6] A plain close: RESULT LEAKED
[11:18:47.304][t5][g6/6/6] window closed
[11:18:47.311][t5][g6/6/6] shutdown starting, deferral taken
[11:18:47.841][t7][g8/8/8] hold: collection done, completing deferral
[11:18:47.848][t5][g8/8/8] !! ReleaseObjects: 1 object(s)
[11:18:49.269][t4][g8/8/8] B deferral held: before GC, alive=False
[11:18:50.814][t4][g14/14/14] B deferral held: RESULT collected
```
The whole of round A — create, show, consolidate, close, and the tracker-host teardown callback — happens at `g0/0/0`: not a single gen0 collection took place while that apartment was alive. The three collections that follow are all too late.
### Which collection is enough
Same close every time, only the work inside the held deferral differs:
| inside the held deferral | result |
|---|---|
| nothing (plain close) | **leaked** |
| `Collect()` + `WaitForPendingFinalizers()` + `Collect()` | **collected** |
| `Collect(0)` | leaked |
| `Collect(2, GCCollectionMode.Optimized, blocking: true, compacting: false)` | leaked |
| `Collect()` alone, no finalizer wait | leaked |
| `Collect()` + `WaitForPendingFinalizers()` + `Collect()`, deferral completed immediately afterwards | **collected** |
Two things follow. The **finalizer wait is the essential half** — a collection on its own, of any generation, releases nothing; the finalizers have to actually run, off the view thread, while that apartment is still pumping so their cross-apartment releases can complete. And the fourth row is exactly what `IReferenceTrackerHost::DisconnectUnusedReferenceSources` does, so even if XAML did call it at teardown it would not be enough on its own.
Completing the deferral immediately after the wait is fine; the pumping window is needed *during* the wait, not after it.
### Regression?
Against .NET Native (UWP, `UseDotNetNativeToolchain`), yes. The same application, same XAML, same view lifecycle does not leak there. It reproduces on .NET 10 in both Debug (CoreCLR) and published NativeAOT builds; the attached log is Debug/CoreCLR.
### Known Workarounds
Subscribe to `DispatcherQueue.ShutdownStarting` on the view's thread, take the deferral, run one `GC.Collect()` / `GC.WaitForPendingFinalizers()` / `GC.Collect()` **off-thread** (blocking the view thread stops the pump and risks the deadlock described in #109538), let the queue pump briefly so the releases that collection queues can land, then complete the deferral.
This costs a UI thread and its apartment for the duration of the hold, per closed window.
### Configuration
- .NET 10.0.12, win-x64
- UWP XAML (`Windows.UI.Xaml`), `UseUwp=true`, `TargetPlatformMinVersion` 10.0.18362.0
- Windows 11 10.0.26200
- Reproduces in Debug (CoreCLR) and in published NativeAOT builds
### Other information
**Why this matters beyond the memory.** In a shipping UWP app ported from .NET Native to .NET 10, the stranded wrappers are eventually finalized, and releasing them then tears down XAML objects from the finalizer thread rather than from the apartment that owned them:
```
__Finalizer.ProcessFinalizers
NativeObjectWrapper.Finalize ComWrappers.cs:658
ReferenceTrackerNativeObjectWrapper.DisconnectTracker ComWrappers.cs:733
ctl::ComBase::ReleaseImpl -> ~XamlRoot
VisualTree::Release -> VisualTree::FinalShutdown visualtree.cpp:686
CUIElement::UnsetRequiresComposition -> RemoveCompositionPeer
~HWCompTreeNodeWinRT -> GetVisualCollectionFromWUCSpineVisual <- fail-fast
```
Fail-fast with `RO_E_CLOSED` (0x80000013), with stowed inner exceptions from C++/WinRT `event_revoker` destructors calling `remove_LostFocus` / `remove_SizeChanged` into the dead apartment. So the leak does not only cost memory; it turns into a process-level fail-fast once those objects are finally collected.
**Related:**
- #114043 — the same scenario (`ReleaseObjects` receiving aggregated objects when a window closes, via microsoft/CsWinRT#1955). That was resolved by having CsWinRT skip objects it cannot unwrap instead of throwing, which fixed the exception but not the lifetime: the aggregated object is still handed over at teardown and still never released. Whether the right answer is for the runtime not to pass aggregated objects to `ReleaseObjects` and release them itself, or for `ReleaseObjects` to be able to handle them, is the open question this issue is really about.
- #109538 / #110551 — why `IReferenceTrackerHost::ReleaseDisconnectedReferenceSources` is now a no-op. The table above says the finalizer wait it used to perform is exactly the half that matters, so it is load-bearing for this scenario — though restoring it alone would not fix this, since nothing collects at teardown in the first place. Note that .NET Native's equivalent (`ICLRServices::FinalizerThreadWait`) was *also* `return S_OK`; it does not need either, because it severs eagerly (below).
- microsoft/microsoft-ui-xaml#10981 — .NET 8 → .NET 10 leak regression with `ComWrappers+ManagedObjectWrapperHolder` accumulating, possibly the same root cause seen from WinUI 3.
**For comparison**, .NET Native's host interface `ICLRServices` carries `GarbageCollect` and `DisconnectRCWsInCurrentApartment`, and the latter's implementation (`System.Private.Interop`, `ComObjectCache.RemoveRCWsForContext`) walks every RCW in the process and severs that apartment's interface pointers eagerly, synchronously, on the dying thread — no GC required. Nothing equivalent happens on .NET 10: `ReleaseExternalObjectsFromCurrentThread` passes on only the wrappers whose `ProxyHandle` still resolves, and of those the aggregated ones are skipped by `ReleaseObjects`, so the rest is left to finalizers that no longer have an apartment to run in.
Contributor guide
Research direction
Start with the attached SpikeGC.zip reproduction and trace the teardown path through IReferenceTrackerHost::NotifyEndOfReferenceTrackingOnThread, TrackerObjectManager.ReleaseExternalObjectsFromCurrentThread, ComWrappers.ReleaseObjects, and ComWrappersSupport.TryUnwrapObject. Compare the .NET 10 path with the referenced .NET Native behavior and related issues. Done means the secondary view's XAML tree is collected without a late finalizer-thread fail-fast.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- desktop-dev, operating-systems
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 30/100