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 7 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.
…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.
|
Follow-up: the
|
| Static | Read by | What a stale cache pins |
|---|---|---|
AnalyzerTreeNode.Language |
every Analyzed*TreeNode.Text, AnalyzerEntityTreeNode.CreateRichText, AnalyzerSearchTreeNode.RunAnalyzer |
LanguageService -> its PropertyChanged subscribers (AssemblyTreeModel, DockWorkspace) -> the whole app graph |
AnalyzerTreeNode.CurrentAssemblyList |
AnalyzerSearchTreeNode.RunAnalyzer |
AssemblyTreeModel -> tree + loaded assemblies |
AnalyzerTreeNode.Analyzers |
AnalyzerEntityTreeNode.LoadChildren, 4 tests directly |
AnalyzerRegistry (harmless) |
RichNodeText.GetLanguageSettings |
OnNodeChanged / Detach for every tree row whose node is IRichTextNode - AssemblyTreeNode is one, so every shown assembly tree |
LanguageSettings -> Parent SessionSettings, plus AssemblyListPane's never-unsubscribed handler (AssemblyListPane.axaml.cs:77) -> the window graph |
Tests that depend on them
- Direct (
AnalyzerTreeNode.Analyzers):AnalyzerConstructorUsesTests,AnalyzerPaneCopyResultsTests,AnalyzerSearchTreeNodeTests,AnalyzerTreeNodeTests. - Indirect via analyzer nodes: all 15 files in
ILSpy.Tests/Analyzers/*.cs(35[AvaloniaTest]s) - anything that materializes anAnalyzed*TreeNode, expands an entity node, or runs a search node. - Indirect via
RichNodeText: every test that shows aMainWindow/ assembly tree - 153 of 236 test files (TestHarness.BootAsync/GetExport<MainWindow>). - Not impacted:
Analyzers/Library/*(23 tests; they buildAnalyzerContextthemselves),DockWorkspaceTests,ToolPaneRegistryTests,ILSpyXEventSourceTests.
Which tests change outcome (measured, caches restored in the working tree)
| Variant | Full ILSpy.Tests (1183) |
TeardownRetentionTests alone |
|---|---|---|
| both caches restored | all pass, canary included (ran at position 1036) | fails |
only RichNodeText cache restored |
- | fails |
only AnalyzerTreeNode caches restored |
- | passes |
So no test's correctness depends on the removal. The caches pin exactly one app graph (the first test's, ~13 MB) for the run - bounded, not per-test growth. The canary trips only when its own window is the first to populate RichNodeText's static (a filtered run), and never for the AnalyzerTreeNode statics (a bare MainWindow does not touch them). The claim in the 7f0afb1 message that the canary "fails on any single anchor being restored" does not hold for these two hunks; the memory win in that commit is the macOS menu and the search drain timer. One latent hazard does remain with the AnalyzerTreeNode cache: later analyzer tests would search test #1's AssemblyList - harmless today only because every BootAsync loads the same list.
Options for keeping the runtime caching
- Reinstate
??=and invalidate on container rebuild (my pick).AppComposition.CreateContaineralready disposes the previous host; addpublic static event Action? ContainerReplaced;fired there, and have the static ctors ofAnalyzerTreeNode/RichNodeTextsubscribe a static lambda that nulls the fields. Runtime path is the old one, the invalidation is a correct invariant ofCreateContaineron its own (an export cached from a disposed host is stale) rather than a test hook, and the canary passes in every ordering becauseCreateContainerruns before the collectability check. ~8 lines. - Test-only reset:
internal static void ResetExportCache()on both classes (InternalsVisibleTo already exists), called fromResetAppStateAttribute.TearDownTestState(must be there, notBeforeTest, since the canary callsTearDownTestState+CreateContainerdirectly). No new runtime API, but the harness carries a whitelist of statics that future caches must be added to - and as measured, the canary will not catch an unlistedAnalyzerTreeNode-style one. - Revert only the
AnalyzerTreeNodehunk, keep theRichNodeTextchange. Smallest diff (no new code), green in every ordering - but keeps the stale-AssemblyListhazard for a future analyzer test that opens its own assembly. - Container-keyed cache (
ReferenceEquals(cachedHost, AppComposition.Current)): no hooks, but the stale graph is only released on the next access, so the canary alone still fails forRichNodeText. Not worth it. - Keep HEAD as is.
GetExporton a[Shared]part is a contract-dictionary lookup plus lifetime lock - what the other 51AppComposition.Current.GetExport/TryGetExportcall sites inILSpy/already do per access, and analyzer rows cache theirRichTextanyway. If kept,AnalyzerRegistry's doc comment ("resolves this registry once") is stale and needs a touch. - Instance-scoped (inject through the analyzer root / VM): correct by construction but touches every
Analyzed*TreeNodeconstructor and the tests thatnewthem. Not worth the diff.
The registry's summary still described the static accessor as resolving it once, which stopped being true when the accessors began going through the current composition host on every access. Assisted-by: Claude:claude-fable-5:Claude Code
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