Stop ILSpy.Tests from retaining every test's app graph (15 GB -> 0.7 GB) - #4012
Stop ILSpy.Tests from retaining every test's app graph (15 GB -> 0.7 GB)#4012christophwille wants to merge 6 commits into
Conversation
…youts The headless test host runs the app without an application lifetime, so the window-closing step in ResetAppState never had a list to work from and every MainWindow the suite showed stayed open - and reachable from the compositor, together with its view-models, assembly tree and loaded assemblies. Measured at about 13 MB per test, 15 GB over the suite, enough to page out the CI runner and stall the tests that scan process module lists. Closing is not sufficient on its own: Avalonia's Button subscribes to its flyout's Opened/Closed and only unsubscribes when the Flyout property changes, and Dock's ToolChromeControl theme hands every tool pane's chrome button one shared MenuFlyout resource, which therefore pinned every closed window's visual tree. The flyouts are detached before the window closes. Assisted-by: Claude:claude-fable-5:Claude Code
…spose WritingOptions is process-wide static state, and the pane subscribes to its PropertyChanged in the constructor. Every composition container that is built and disposed (the headless UI test suite does that per test) left its pane behind on that event, and through the pane's LanguageService the rest of the container's object graph with it. System.Composition disposes IDisposable shared parts with the container, which is the moment to let go. Assisted-by: Claude:claude-fable-5:Claude Code
…progress The search pane's progress bar was permanently indeterminate and merely hidden when idle, and the decompiler view's bar defaults to indeterminate mode whether or not a decompilation is running. The indeterminate indicator is an infinite keyframe animation that keeps running - and keeps the control's whole visual tree alive through the render clock - for as long as the pseudo-class is set, hidden or not. Both bars now go indeterminate only for the duration of the work. Assisted-by: Claude:claude-fable-5:Claude Code
The sampler was a one-off to find out why process-module walks stall on the Windows runner; it did its job. The answer was memory: ILSpy.Tests grows to ~15 GB over its run and pages the box out, so every module read hard-faults - a disk-bound problem that inspecting processes concurrently could not and did not shorten. That leak is fixed separately (#4012); the scan goes back to its original form. Assisted-by: Claude:claude-fable-5:Claude Code
How the retention chains were found (heap analysis on the .NET 11 preview runtime)Notes for the next person who has to do this, because the stock tooling does not work yet. What does not work: What works:
Typical anchors to check first once you have a root path: statics with events ( |
christophwille
left a comment
There was a problem hiding this comment.
Review: correctness + verification of the memory claim
I reviewed the diff, reproduced the suite locally, and measured the memory claim independently. Summary: the four anchors are real and the direction is right, but the headline number does not reproduce off Windows, and there is at least one more retention root of exactly the same shape that the harness still leaves behind (an open ContextMenu) - which is also my best explanation for the Desktop (Windows) (Debug) failure.
1. Independent memory measurement (macOS 15.6, arm64, Debug, ILSpy.Tests, same 1181 tests)
Sampled dotnet-counters (System.Runtime) across full runs of both branches:
master (0879e35) |
this PR (c0a74fc) | |
|---|---|---|
| gen2 heap after last GC, start -> end | 210 MB -> 6406 MB | 174 MB -> 5076 MB |
| gen2 fragmentation at end | 1 MB | 835 MB |
| live gen2 at end (heap - frag) | ~4.5 GB | ~4.0 GB |
| peak working set | 10.7 GB | 8.9 GB |
| gen2 collections | 10 | 10 |
| wall clock | 4m34s | 3m52s |
Both runs green (1181 passed / 3 skipped). The wall-clock win reproduces (~15%). The memory win does not: retained gen2 still grows monotonically and near-linearly across the run on this branch, ending at ~4 GB live. That is roughly a 10-20% improvement, not 15.8 GB -> 0.7 GB.
I can't tell from here whether the remaining growth is Windows-vs-macOS or whether the Windows measurement was optimistic, but as it stands the PR description's "peak private bytes 705 MB" should not be treated as verified, and the hunt shouldn't be declared finished. Raw data and the sampling scripts are reproducible with dotnet-counters collect -p <pid> --counters System.Runtime around a plain ./ILSpy.Tests run.
2. The anchor the harness still misses: an open ContextMenu roots the whole window
This is the same bug class as anchor 2 (shared MenuFlyout), and it is arguably a bigger root. From Avalonia.Controls.Platform.DefaultMenuInteractionHandler.AttachCore (decompiled with this repo's ilspycmd):
_root = Menu.TopLevel;
_root?.AddHandler(InputElement.PointerPressedEvent, RootPointerPressed, RoutingStrategies.Tunnel);
if (_root is WindowBase windowBase) windowBase.Deactivated += WindowDeactivated;
_inputManagerSubscription = InputManager?.Process.Subscribe(RawInput);InputManager.Instance is process-global. While a menu is open, the chain InputManager.Instance -> subscription -> DefaultMenuInteractionHandler -> Menu -> Menu.TopLevel pins the entire window, its visual tree, its view-models and its assemblies - exactly the ~13 MB/test shape you measured. DetachCore (which disposes that subscription) only runs on MenuBase.Close(). Destroying the window out from under an open menu does not call it.
Several tests open a tree context menu and never dismiss it (DecompileInNewViewTests.Right_Clicking_An_Unselected_Row_Does_Not_Change_The_Selection is one), so AfterTest closes those windows with the menu still open. Suggested extension of DetachFlyouts - one pass, both roots:
foreach (var control in window.GetVisualDescendants().OfType<Control>())
{
control.ContextMenu?.Close();
if (control is Button { Flyout: not null } button)
button.Flyout = null;
}3. Windows (Debug) CI failure
DecompileInNewViewTests.Right_Clicking_A_Second_Row_Moves_The_Context_Highlight_To_It failed with Expected Row(nodeC).Classes {empty} to contain "contextTarget". Only Desktop (Windows) (Debug) failed; Release, Linux and macOS passed, and I could not reproduce it in two full local runs of this branch.
The class is only ever missing if AssemblyListPane.OnTreeContextRequested did not run for that click, or OnContextMenuOpening cleared it because BuildContextMenuForCurrentState returned null. The test has two unguarded legs that produce the first case:
Dismiss()pumps a fixed4 x 20msafter Escape and never checks that the menu actually closed. If it is still open, the light-dismiss layer eats the next right-press and the tree never seesContextRequested- precisely the observed symptom.RightClick()computes the point fromrow.Boundswith no hit-test check. This is the exact fragility that a134c8b ("Aim the tree-gesture pointer tests at a hit-testable row") had to fix inHeadlessMmbPointerTestsa few hours earlier;DecompileInNewViewTestsstill has the old computation.
Proposal, independent of whether this PR caused it: hoist HeadlessMmbPointerTests.TryGetRowClickPoint into a shared helper and use it here, and make Dismiss()/RightClick() wait on Tree.ContextMenu.IsOpen instead of on a fixed delay. Also fold the menu state into the assertion message so the next CI failure explains itself instead of printing {empty}. Fixing (2) above removes the stale-menu leg as well, which is why I think the two are related.
4. Smaller items
Inline comments on the diff. Nothing blocking beyond the above.
Verified while reviewing (no action needed): System.Composition does dispose [Shared] IDisposable parts on CompositionHost.Dispose() (DisposalFeature.RewriteActivator -> LifetimeContext.AddBoundInstance -> LifetimeContext.Dispose), so anchor 3 works; and Window.HandleClosed does raise WindowClosedEvent, so openWindows does drain on a normal close.
| window.Close(); | ||
| DetachFlyouts(window); | ||
| window.Close(); | ||
| } |
There was a problem hiding this comment.
Two robustness gaps in this loop.
Nothing closes an open menu before the window dies. DetachFlyouts covers Button.Flyout, but a ContextMenu left open by a test keeps DefaultMenuInteractionHandler subscribed to the process-global InputManager.Instance.Process, and that handler holds Menu.TopLevel - i.e. this window and everything under it - for the rest of the run. Several tests here open a tree context menu and never dismiss it. Closing menus in the same pass is a one-line extension and removes a retention root of the same size as the flyout one:
foreach (var control in window.GetVisualDescendants().OfType<Control>())
{
control.ContextMenu?.Close();
if (control is Button { Flyout: not null } button)
button.Flyout = null;
}No error containment. If any Closing/Closed handler throws for the first window (MainWindow.OnClosing guards its own SaveLayout, but view OnDetachedFromVisualTree handlers are not guarded), the foreach aborts: every remaining window stays open and stays in the static openWindows list for the whole run - the exact retention this PR removes - and the exception surfaces as a teardown error on a test that otherwise passed. Wrap the body in try/catch and openWindows.Remove(window) unconditionally so the tracking list can never become the thing that retains a window.
| if (sender is Window window) | ||
| openWindows.Remove(window); | ||
| }); | ||
| } |
There was a problem hiding this comment.
None of the four leak fixes has a regression test, which is at odds with the repo's TDD rule for new behaviour. Concretely: if someone later drops IDisposable from DebugStepsPaneModel, reverts this tracking, or restores IsIndeterminate="True" in the XAML, the suite stays green and the runner quietly goes back to paging - the failure mode is an intermittent 60s timeout in a different project, which is the hardest possible signal to trace back here.
A single cheap guard covers most of it: hold a WeakReference to the container's MainWindow (and to the DebugStepsPaneModel) at the end of a test, let the next BeforeTest rebuild the container, force a full GC, and assert the reference is dead. DecompilerViewTests already uses the GC.Collect()/WaitForPendingFinalizers() pattern, so there is precedent.
| // is an infinite animation, and one that ran while idle would keep the render clock | ||
| // busy for as long as the pane exists. | ||
| var search = AppComposition.Current.GetExport<SearchPaneModel>(); | ||
| progress!.IsIndeterminate.Should().BeFalse("nothing is running yet"); |
There was a problem hiding this comment.
This assertion is now vacuous in the failure case it is supposed to catch. ProgressBar.IsIndeterminate defaults to false, so if the pane's DataContext has not been set to this SearchPaneModel yet (or is a different instance than the one resolved from composition), line 124 passes for the wrong reason and line 127 fails with "the indicator runs in indeterminate mode..." - which points at the binding direction rather than at the missing DataContext.
Add pane.DataContext.Should().BeSameAs(search) before line 124, so the test fails where the real problem is. The single RunJobs() on line 126 is also load-bearing now where the old assertion read a static XAML value; a short Waiters.WaitForAsync(() => progress.IsIndeterminate) would take the timing out of it entirely.
| if (languageService != null) | ||
| languageService.PropertyChanged -= OnLanguageServiceChanged; | ||
| DetachFromLanguage(); | ||
| } |
There was a problem hiding this comment.
Confirmed this is actually wired: System.Composition.TypedParts.ActivationFeatures.DisposalFeature.RewriteActivator registers every IDisposable part via LifetimeContext.AddBoundInstance, and LifetimeContext.Dispose walks that list - so AppComposition.CreateContainer()'s current?.Dispose() does reach this. No change needed.
One note for a follow-up: Dispose() is not idempotent-safe against re-use. DebugStepsPaneModel is a [Shared] singleton that ShowToolPane can re-surface, so if anything ever starts disposing dockables on pane close (Dock does not today - I checked Dock.Model/Dock.Avalonia for IDisposable usage), closing and reopening the pane would leave it permanently dead: unsubscribed from the language service, the message bus and the writing options, with nothing that re-subscribes. A one-line disposed guard plus re-subscription in TryAttachToCurrentLanguage would make that safe, or a comment stating the container is the only owner.
…ention canary The app-level NativeMenu declared in App.axaml lives as long as the process, while every MainWindow builds its own Help items over its own command instances (AboutCommand reaches the DockWorkspace and, through it, the whole app graph). PromoteHelpToMacAppMenu inserted each window's items without taking the previous window's out and nothing removed them on close, so on macOS the headless suite kept every test's app graph alive - the same 13 MB per test as the anchors fixed earlier on this branch, and the reason the memory win did not reproduce on macOS (retained gen2 still climbing to ~4 GB there while a Windows run peaks at 0.7 GB). Forcing the macOS path on Windows reproduces the growth (14.3 GB peak private bytes over the suite); withdrawn, it is 0.7 GB. Three smaller anchors of the same kind, found while making the canary below hold in the full suite: RichNodeText and AnalyzerTreeNode cached the first container's exports in statics, which subscribed later windows to a stale settings object, handed later analyzers the first test's assembly list, and kept the first app graph reachable for the run; and a search still in flight when its container went away kept its drain timer and IsSearching - hence the pane's indeterminate progress animation on the render clock - alive, retaining every window a search test closed mid-run (about 30 of them, ~400 MB). The canary test closes a MainWindow the way the per-test teardown does and waits for it to become collectable. It fails on any single anchor being restored (checked by leaving DetachFlyouts out), which is the regression guard the individual anchor fixes lacked; the teardown body is exposed as TearDownTestState so the test performs exactly what AfterTest does. Assisted-by: Claude:claude-fable-5:Claude Code
…ding ProgressBar.IsIndeterminate defaults to false, so the "nothing is running yet" assertion would also pass against a pane whose DataContext is not the resolved SearchPaneModel, and the failure would only surface one line later, blamed on the binding direction rather than on the missing DataContext. Assisted-by: Claude:claude-fable-5:Claude Code
Follow-up on the review: the macOS number was real, and it had a cause of its ownThanks for measuring - the discrepancy was not a measurement error on either side. There is a fifth anchor that only exists on macOS, which is why the win reproduced on Windows (15.8 GB -> 0.7 GB) and not on your machine. The macOS-only anchor
The open ContextMenuNot confirmed as a root. I closed a window with the tree's context menu still open and rooted it from a dump: no path through Regression testAdopted.
Three transient roots had to be accounted for so the canary does not false-positive: assembly-load completions posted to the dispatcher after teardown, the compositor's pending batch for the closed target (commits are throttled behind the previous batch's completion, which returns via the thread pool, and the headless render loop only ticks on request), and the Debug async frame - hence a Smaller items
Full local suite after all of this: 1186 tests green, 5m59, peak private bytes 691 MB. |
The app-level NativeMenu is process-wide, so the withdrawal a window does on Closed has to name the items that window put there. Withdrawing "whatever is promoted right now" is correct only while one window exists at a time: with two, closing the older one takes the newer one's About / Check for Updates out of the macOS app menu, and nothing ever puts them back. Not reachable today - MainWindow is [Shared] and Attach runs from its ctor - but the failure mode is silent and permanent, and carrying the list costs nothing. Removing an item that is already gone is a no-op, so a superseded window's Closed stays harmless. The promotion tests also have to leave the app menu as they found it: it is declared on Application and outlives the test, it is not gated on macOS, and on Windows and Linux nothing re-promotes over the leftovers. Assisted-by: Claude:claude-opus-5:Claude Code
Re-review after 7f0afb1 / 2881d8c: the macOS number reproduces, and two of my findings were wrongI re-measured and re-derived everything from scratch on macOS 15.6 / arm64 / Debug. Two corrections to my earlier review, then two small findings which I have pushed fixes for. Correction 1: the memory win does reproduce. My measurement predated the fix.Full
My "the headline number does not reproduce on macOS" was measuring the branch before The mechanism holds up independently: Correction 2: you are right about the open ContextMenu; I was wrong.
|
Problem
ILSpy.Tests(the headless Avalonia suite) retains the whole app object graph of every test it runs: private bytes grow linearly by ~13 MB per test to ~15 GB by the end of the 1180-test run (measured locally and, via a temporary sampler, on thewindows-2025runner, whose 16 GB box then pages out every idle process). That is what has been failing the Windows CI jobs intermittently: the process-explorer tests inILSpy.TestsandILSpy.Tests.Windowsscan every process's module list, and on a paged-out runner that walk hard-faults through ~150 processes and blows past its 60 s budget (100–400 s measured; 13–16 min when the decompiler suite added server GC on top).Anchors found (ClrMD
gcrooton dumps of the running suite)ApplicationLifetime, soResetAppState's window-closing loop had no list to iterate. Fixed by trackingWindow.WindowOpenedEvent/WindowClosedEvent(the same class handlers the desktop lifetime uses) and closing after each test.MenuFlyoutresource in Dock'sToolChromeControltheme. Avalonia'sButtonsubscribesOpened/Closedon its flyout and only unsubscribes when theFlyoutproperty changes, never on detach; every tool pane's chrome button shares oneMenuFlyout, which therefore pinned every closed window's visual tree. The harness detaches the flyouts before closing.DebugStepsPaneModelsubscribing to the staticWritingOptions.PropertyChangedwithout ever unsubscribing (Debug builds only) - retains each container's pane and, via itsLanguageService, the rest of the container. NowIDisposable; System.Composition disposes it with the container.IsIndeterminate="True"(only hidden when idle) and the decompiler view's bar defaults to indeterminate whether or not a decompile runs. The indicator is an infinite keyframe animation that keeps running - and keeps the visual tree alive through the render clock - as long as the pseudo-class is set, visible or not. Both now go indeterminate only while work is in progress (3 and 4 are also small runtime wins for the app itself).Result
Full local run of
ILSpy.Tests: peak private bytes 705 MB (was 15.8 GB), 6m36 (was 10m30), all tests green (one test asserting the old always-indeterminate binding updated).🤖 Generated with Claude Code