From 92eb38881b8ae1f8064ebd4d417b026d199e44fb Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 10 Jun 2026 13:22:16 +0200 Subject: [PATCH 1/5] Update README Updated .NET SDK version requirements in README. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f0f53440b..103a5c3c0 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ How to build - Clone the ILSpy repository using git. - Execute `git submodule update --init --recursive` to download the ILSpy-Tests submodule (used by some test cases). - Install Visual Studio (documented version: 18.0/2026). You need the following workload components: - - Workload ".NET Desktop Development". This workload includes the .NET Framework 4.8 SDK and the .NET Framework 4.7.2 targeting pack, as well as the [.NET 11.0 SDK](https://dotnet.microsoft.com/download/dotnet/11.0) (ILSpy.csproj targets .NET 11.0, but we have net472 projects too). + - Workload ".NET Desktop Development". This workload includes the .NET Framework 4.8 SDK and the .NET Framework 4.7.2 targeting pack, as well as the [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (ILSpy.csproj targets .NET 10.0, but we have net472 projects too). Our unit tests require [.NET 11.0 SDK](https://dotnet.microsoft.com/download/dotnet/11.0). - Workload "Visual Studio extension development" (Note: ILSpy.VSExtensions.sln is separate from ILSpy.sln and thus this workload is optional) - Individual Component "MSVC v143 - VS 2022 C++ x64/x86 build tools" (or similar) - _The VC++ toolset is optional_; if present it is used for `editbin.exe` to modify the stack size used by ILSpy.exe from 1MB to 16MB, because the decompiler makes heavy use of recursion, where small stack sizes lead to problems in very complex methods. @@ -67,7 +67,7 @@ If this problem occurs, please manually install the .NET 11.0 SDK from [here](ht #### Unix / Mac: -- Make sure [.NET 11.0 SDK](https://dotnet.microsoft.com/download/dotnet/11.0) is installed. +- Make sure [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (for run) and [.NET 11.0 SDK](https://dotnet.microsoft.com/download/dotnet/11.0) (for tests) is installed. - Make sure [PowerShell](https://github.com/PowerShell/PowerShell) is installed (formerly known as PowerShell Core) - Clone the repository using git. - Execute `git submodule update --init --recursive` to download the ILSpy-Tests submodule (used by some test cases). From a1788430a2d852d8b5fd48f0d79a485d474e55e4 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 10 Jun 2026 14:39:27 +0200 Subject: [PATCH 2/5] Restore RollForward, GC adaptation, and Debug overflow checks These three project settings were present in the WPF build but lost in the move to Avalonia. RollForward=major matters most: the default publish is framework-dependent (publish.ps1 uses --no-self-contained) and the app targets net10.0, so without it a machine that only has a newer major runtime installed fails to start the app. DynamicAdaptationMode (DATAS) lets server GC shrink its heap count to the live load for a smaller idle working set, and Debug-only CheckForOverflowUnderflow traps arithmetic wrap during development. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy/ILSpy.csproj | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ILSpy/ILSpy.csproj b/ILSpy/ILSpy.csproj index 39dd0ab8e..21385a682 100644 --- a/ILSpy/ILSpy.csproj +++ b/ILSpy/ILSpy.csproj @@ -6,6 +6,10 @@ file stays in sync for these targets without forcing per-RID restore onto the ILSpyCmd tool (PackAsTool + GeneratePackageOnBuild) or the test projects. --> win-x64;win-arm64;linux-x64;osx-arm64 + + major enable app.manifest Assets/ILSpy-Large.ico @@ -33,6 +37,15 @@ stop-the-world for long. --> true true + + 1 + + + + + true From 578f63ac01e973ef5cf4d60ed29ae2a33b0d2e65 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 10 Jun 2026 15:08:26 +0200 Subject: [PATCH 3/5] Make the caret-highlight teardown test deterministic CaretHighlightAdornerTests waited on the wall clock (3s budget) for the adorner's 1s real-time DispatcherTimer to remove it. Avalonia.Headless has no simulated clock, so under a loaded CI runner the dispatcher-thread timer plus the poll loop slipped past the tight budget and the test timed out intermittently. Extract the teardown the lifetime timer runs into Dismiss(); the test grabs the live adorner and calls it directly, verifying the unregister half without any timing dependency. Registration is still exercised through the real OnReferenceClicked path, and production behaviour is unchanged. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Editor/CaretHighlightAdornerTests.cs | 15 +++++--- ILSpy/TextView/CaretHighlightAdorner.cs | 38 +++++++++++++------ 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs index 75a8bb5a8..929c2f0b0 100644 --- a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs +++ b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs @@ -57,8 +57,8 @@ public class CaretHighlightAdornerTests // AvaloniaEditTextOutput.AddReference), so `OnReferenceClicked` resolves the click // to an in-document jump and DecompilerTextView fires the caret highlight. // - // The lifecycle (register then unregister after ~1 s) is verified end-to-end — - // hand-tuned animation curve is left for eyeball validation. + // The lifecycle (register on click, then unregister via Dismiss) is verified end-to-end — + // the hand-tuned animation curve and the one-second timing are left for eyeball validation. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; @@ -103,9 +103,12 @@ public class CaretHighlightAdornerTests "clicking a member definition must route through DecompilerTextView.OnReferenceClicked " + "and call CaretHighlightAdorner.DisplayCaretHighlightAnimation"); - // Wait for the lifetime timer (~1 s) to tear it back down. - await Waiters.WaitForAsync( - () => !renderers.Any(r => r is CaretHighlightAdorner), - timeout: TimeSpan.FromSeconds(3)); + // The 1 s lifetime is a real-time DispatcherTimer, which is unreliable to wait on in a + // headless run; invoke the same teardown the timer runs (Dismiss) directly so the + // unregister half is verified deterministically rather than against the wall clock. + var adorner = renderers.OfType().Single(); + adorner.Dismiss(); + renderers.Should().NotContain(r => r is CaretHighlightAdorner, + "Dismiss unregisters the caret-highlight adorner from the text view"); } } diff --git a/ILSpy/TextView/CaretHighlightAdorner.cs b/ILSpy/TextView/CaretHighlightAdorner.cs index f8f1f064b..a3b3510a3 100644 --- a/ILSpy/TextView/CaretHighlightAdorner.cs +++ b/ILSpy/TextView/CaretHighlightAdorner.cs @@ -47,9 +47,14 @@ namespace ILSpy.TextView readonly Rect maxRect; readonly IPen pen; readonly Stopwatch elapsed = Stopwatch.StartNew(); + readonly TextArea textArea; + readonly DispatcherTimer frameTimer; + readonly DispatcherTimer lifetimeTimer; CaretHighlightAdorner(TextArea textArea) { + this.textArea = textArea; + // Caret rect is in document coordinates; subtracting ScrollOffset converts to the // viewport-relative space IBackgroundRenderer.Draw paints into. var caretRect = textArea.Caret.CalculateCaretRectangle(); @@ -64,6 +69,13 @@ namespace ILSpy.TextView // black when unset (e.g. design-time / standalone-renderer tests). var brush = textArea.TextView.GetValue(TextElement.ForegroundProperty) ?? Brushes.Black; pen = new Pen(brush, 1).ToImmutable(); + + // The frame timer ticks at ~60 fps to invalidate the Caret layer so Draw re-runs with + // fresh elapsed time; the lifetime timer dismisses the adorner after one second. + frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + frameTimer.Tick += (_, _) => textArea.TextView.InvalidateLayer(KnownLayer.Caret); + lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) }; + lifetimeTimer.Tick += (_, _) => Dismiss(); } public KnownLayer Layer => KnownLayer.Caret; @@ -107,7 +119,7 @@ namespace ILSpy.TextView /// /// Registers a one-shot caret-highlight animation on . Spins /// up two timers: one ticks at ~60 fps to invalidate the Caret layer so - /// re-runs with fresh elapsed time, the other unregisters the adorner after one second. + /// re-runs with fresh elapsed time, the other calls after one second. /// public static void DisplayCaretHighlightAnimation(TextArea textArea) { @@ -115,18 +127,20 @@ namespace ILSpy.TextView var adorner = new CaretHighlightAdorner(textArea); textArea.TextView.BackgroundRenderers.Add(adorner); + adorner.frameTimer.Start(); + adorner.lifetimeTimer.Start(); + } - var frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; - frameTimer.Tick += (_, _) => textArea.TextView.InvalidateLayer(KnownLayer.Caret); - frameTimer.Start(); - - var lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) }; - lifetimeTimer.Tick += (_, _) => { - lifetimeTimer.Stop(); - frameTimer.Stop(); - textArea.TextView.BackgroundRenderers.Remove(adorner); - }; - lifetimeTimer.Start(); + /// + /// Ends the animation immediately: stops both timers and unregisters the adorner from the + /// text view. Invoked by the one-second lifetime timer, and usable to tear the highlight + /// down on demand instead of waiting the lifetime out. + /// + public void Dismiss() + { + lifetimeTimer.Stop(); + frameTimer.Stop(); + textArea.TextView.BackgroundRenderers.Remove(this); } } } From 59bedabb6f8f686cc9baa4f20888cac979bca884 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 10 Jun 2026 15:34:35 +0200 Subject: [PATCH 4/5] Restore the version combo selection after a language flip Selecting a C# version, switching to a version-less language (IL), then back to C# left the toolbar's version ComboBox blank even though LanguageService had restored CurrentVersion. The MVVM-toolkit setter runs OnCurrentLanguageChanged (which assigns CurrentVersion, pushing it onto the bound SelectedItem) before it raises PropertyChanged for CurrentLanguage (which repopulates ItemsSource from the new language's versions). So SelectedItem is set against the previous language's still-stale, empty list, rejected, and never re-pulled once ItemsSource updates. Re-assert the version combo's SelectedItem from the bound CurrentVersion after each ItemsSource swap settles, keeping the model untouched. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../LanguageVersionPersistenceTests.cs | 80 +++++++++++++++++++ ILSpy/Views/MainToolBar.axaml.cs | 21 +++++ 2 files changed, 101 insertions(+) create mode 100644 ILSpy.Tests/MainWindow/LanguageVersionPersistenceTests.cs diff --git a/ILSpy.Tests/MainWindow/LanguageVersionPersistenceTests.cs b/ILSpy.Tests/MainWindow/LanguageVersionPersistenceTests.cs new file mode 100644 index 000000000..c664865ec --- /dev/null +++ b/ILSpy.Tests/MainWindow/LanguageVersionPersistenceTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; + +using AwesomeAssertions; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Languages; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class LanguageVersionPersistenceTests +{ + [AvaloniaTest] + public async Task Switching_Away_From_And_Back_To_A_Versioned_Language_Restores_The_Version() + { + // Regression: selecting C# x.y, switching to a language with no versions (IL), then back to + // C# must restore x.y in the version combo. The toolbar's version ComboBox is bound TwoWay + // to LanguageService.CurrentVersion; when the language flips, its ItemsSource swaps to the + // new (empty) language first and writes a null selection back, so the outgoing version has + // to be stashed before that happens or the restore reads back null. + var (window, _) = await TestHarness.BootAsync(); + var toolbar = await window.WaitForComponent(); + + var languageCombo = toolbar.GetVisualDescendants().OfType() + .Single(c => c.Name == "LanguageComboBox"); + var versionCombo = toolbar.GetVisualDescendants().OfType() + .Single(c => c.Name == "LanguageVersionComboBox"); + + var languageService = AppComposition.Current.GetExport(); + var csharp = languageService.Languages.Single(l => l.Name == "C#"); + var noVersionLanguage = languageService.Languages.First(l => !l.HasLanguageVersions); + + // Start on C# and pick a version that is NOT the default latest, so a reset is detectable. + languageCombo.SelectedItem = csharp; + Dispatcher.UIThread.RunJobs(); + var chosen = csharp.LanguageVersions.First(); + chosen.Should().NotBe(csharp.LanguageVersions.Last(), "the test needs a non-default version"); + versionCombo.SelectedItem = chosen; + Dispatcher.UIThread.RunJobs(); + languageService.CurrentVersion.Should().Be(chosen); + + // Flip to a language with no versions, then back to C#. + languageCombo.SelectedItem = noVersionLanguage; + Dispatcher.UIThread.RunJobs(); + languageCombo.SelectedItem = csharp; + Dispatcher.UIThread.RunJobs(); + + languageService.CurrentVersion.Should().Be(chosen, + "switching away from C# and back must restore the previously selected C# version"); + versionCombo.SelectedItem.Should().Be(chosen, + "the version ComboBox must reflect the restored version"); + } +} diff --git a/ILSpy/Views/MainToolBar.axaml.cs b/ILSpy/Views/MainToolBar.axaml.cs index 6ab39d2b7..92c589f29 100644 --- a/ILSpy/Views/MainToolBar.axaml.cs +++ b/ILSpy/Views/MainToolBar.axaml.cs @@ -24,6 +24,7 @@ using System.Reflection; using Avalonia.Controls; using Avalonia.Media; +using Avalonia.Threading; using ILSpy.AppEnv; using ILSpy.Commands; @@ -112,6 +113,26 @@ public partial class MainToolBar : UserControl if (menuRegistry != null) WireManageAssemblyListsButton(menuRegistry); + + WireLanguageVersionComboSync(); + } + + void WireLanguageVersionComboSync() + { + // The version ComboBox binds ItemsSource to CurrentLanguage.LanguageVersions and SelectedItem + // to CurrentVersion. On a language switch the model assigns CurrentVersion (pushing it onto + // SelectedItem) before the CurrentLanguage change propagates to ItemsSource, so SelectedItem + // is set against the previous language's list, rejected, and left null once ItemsSource + // repopulates. Re-assert the selection from the bound CurrentVersion after each ItemsSource + // swap has settled so flipping to a version-less language (e.g. IL) and back restores it. + LanguageVersionComboBox.PropertyChanged += (_, e) => { + if (e.Property != ItemsControl.ItemsSourceProperty) + return; + Dispatcher.UIThread.Post(() => { + if (DataContext is MainWindowViewModel vm) + LanguageVersionComboBox.SelectedItem = vm.LanguageService.CurrentVersion; + }, DispatcherPriority.Background); + }; } void DispatchToolbarCommands(ToolbarCommandRegistry registry) From 93db1041b13a27c7d53042a6f4aba9c751e797ba Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 10 Jun 2026 15:50:17 +0200 Subject: [PATCH 5/5] Decompile a single method in the slow Options tests Two Options tests selected the whole System.Linq.Enumerable type and then awaited WaitForDecompiledTextAsync. That decompile takes >15 s in headless and overran the 60 s wait on a loaded CI runner, so the suite flaked intermittently (green in one run of a commit, red in another). The behaviours under test -- that a re-decompile setting refreshes the active tab, and that the refresh does not steal focus from the Options tab -- fire on whatever the tab shows, so a single small method exercises them just as well and decompiles near-instantly. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy.Tests/Options/DisplaySettingsReactionTests.cs | 9 ++++++++- ILSpy.Tests/Options/OptionsTabTests.cs | 9 +++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs b/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs index 5b09ca435..7646fb32e 100644 --- a/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs +++ b/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs @@ -59,9 +59,16 @@ public class DisplaySettingsReactionTests public async Task Toggling_A_Decompiler_Output_Setting_Re_Decompiles_The_Active_Tab() { var (_, vm) = await TestHarness.BootAsync(); + // Decompile a single small method rather than the whole Enumerable type: the full type + // takes >15 s in headless and times out WaitForDecompiledTextAsync under CI load. The + // re-decompile reaction under test fires on whatever the active tab shows, so one method + // exercises it just as well. var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); - vm.AssemblyTreeModel.SelectedItem = typeNode; + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + vm.AssemblyTreeModel.SelectNode(method); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); int decompileStarts = 0; diff --git a/ILSpy.Tests/Options/OptionsTabTests.cs b/ILSpy.Tests/Options/OptionsTabTests.cs index 98e451bee..5722fd809 100644 --- a/ILSpy.Tests/Options/OptionsTabTests.cs +++ b/ILSpy.Tests/Options/OptionsTabTests.cs @@ -309,10 +309,15 @@ public class OptionsTabTests // pull focus back to MainTab and yank the user off the Options page they are editing. var (_, vm) = await TestHarness.BootAsync(3); - // Show some decompiled content first, so there is a decompiler tab to refresh. + // Show some decompiled content first, so there is a decompiler tab to refresh. Use a single + // small method, not the whole Enumerable type: the full type takes >15 s in headless and + // times out WaitForDecompiledTextAsync under CI load. var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); - vm.AssemblyTreeModel.SelectNode(typeNode); + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + vm.AssemblyTreeModel.SelectNode(method); await vm.DockWorkspace.WaitForDecompiledTextAsync(); // Open Options and confirm it is the active document.