diff --git a/ILSpy.Tests/ResetAppStateAttribute.cs b/ILSpy.Tests/ResetAppStateAttribute.cs index d5f3d39c8..60305b4f4 100644 --- a/ILSpy.Tests/ResetAppStateAttribute.cs +++ b/ILSpy.Tests/ResetAppStateAttribute.cs @@ -19,6 +19,8 @@ using System; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; @@ -90,8 +92,15 @@ public sealed class ResetAppStateAttribute : Attribute, ITestAction if (Application.Current == null || !Dispatcher.UIThread.CheckAccess()) return; + // Drive background work to quiescence BEFORE the next test rebuilds the composition. A test + // that triggers a decompile spawns a Task.Run plus dispatcher continuations and rarely awaits + // them to completion; left running, that continuation lands during the next test and reads + // the swapped-in AppComposition.Current -- the dominant source of order-dependent flakiness. + // Cancel the in-flight decompiles and pump the dispatcher until they unwind (bounded). + DrainPendingWork(); + // Close any windows the test showed so their view-models (alive and weakly subscribed to - // MessageBus) can't react to events raised by later tests, then drain the dispatcher. + // MessageBus) can't react to events raised by later tests, then drain once more. if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { foreach (var window in desktop.Windows.ToArray()) @@ -100,6 +109,32 @@ public sealed class ResetAppStateAttribute : Attribute, ITestAction Dispatcher.UIThread.RunJobs(); } + static void DrainPendingWork() + { + Task quiesce; + try + { + quiesce = AppComposition.Current.GetExport().CancelPendingOperationsAsync(); + } + catch + { + // Composition not available (a plain [Test], or a boot that never built the workspace). + Dispatcher.UIThread.RunJobs(); + return; + } + + // We're on the dispatcher thread, so we can't block-await: pump the queue (which runs the + // cancelled decompiles' continuations) and let the worker threads post, until everything has + // settled or a safety deadline passes. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (!quiesce.IsCompleted && DateTime.UtcNow < deadline) + { + Dispatcher.UIThread.RunJobs(); + Thread.Sleep(5); + } + Dispatcher.UIThread.RunJobs(); + } + void DeletePreviousSession() { if (previousSessionDir == null) diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 9f4f86c79..b701c918a 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -426,6 +426,26 @@ namespace ILSpy.Docking } } + /// + /// Cancels every in-flight decompile across all document tabs and returns a task that + /// completes once they have unwound. Drives the workspace to quiescence so a background + /// decompile can't keep running and post a continuation later -- e.g. on app shutdown, or + /// between headless tests where it would otherwise land in the next test's rebuilt + /// composition and read the swapped-in singletons. + /// + internal Task CancelPendingOperationsAsync() + { + if (Layout is not IDockable root) + return Task.CompletedTask; + var pending = FlattenDocumentDocks(root) + .SelectMany(dock => dock.VisibleDockables?.OfType() ?? Enumerable.Empty()) + .Select(tab => tab.Content) + .OfType() + .Select(tab => tab.CancelPendingAsync()) + .ToArray(); + return pending.Length == 0 ? Task.CompletedTask : Task.WhenAll(pending); + } + // Set true while syncing the tree's selection FROM the active tab so the // SelectionChanged handler doesn't bounce back into ShowSelectedNode and overwrite // MainTab.Content with the carved-out tab's node. diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 2e52d1eec..a93dd60b9 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -50,6 +50,9 @@ namespace ILSpy.TextView public sealed partial class DecompilerTabPageModel : ContentPageModel { CancellationTokenSource? activeCts; + // The in-flight fire-and-forget decompile, kept so a caller (e.g. teardown / shutdown) can + // cancel it and await its completion instead of letting it post continuations later. + Task? pendingDecompile; [ObservableProperty] private string text = string.Empty; @@ -316,12 +319,24 @@ namespace ILSpy.TextView // faults that the finalizer thread rethrows much later, hiding the originating bug. void StartDecompile() { - DecompileAsync().ContinueWith(t => { + pendingDecompile = DecompileAsync().ContinueWith(t => { if (t.Exception is { } ex) System.Diagnostics.Debug.WriteLine($"DecompileAsync faulted: {ex.Flatten()}"); }, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); } + /// + /// Cancels any in-flight decompile and returns a task that completes once it has unwound, so + /// callers can drive the work to quiescence instead of leaving a background continuation to + /// land later (e.g. on app shutdown, or between headless tests where it would otherwise post + /// into the next test's freshly-rebuilt composition). + /// + internal Task CancelPendingAsync() + { + activeCts?.Cancel(); + return pendingDecompile ?? Task.CompletedTask; + } + // Process-wide counter so the AppLog markers can distinguish "first decompile after // boot" (carries the JIT / pipeline-init cold cost) from subsequent runs. Incremented // at the top of every DecompileAsync invocation including the empty-node short-circuit