Browse Source

Drive decompiles to quiescence between headless tests

A test that triggers a decompile spawns a Task.Run plus dispatcher continuations
and rarely awaits them; the per-test ResetAppState closed windows and drained the
dispatcher but never cancelled the background work, so a continuation could land
during the next test and read the swapped-in AppComposition.Current -- the
dominant source of order-dependent flakiness (a different test tipped each run).

Give DecompilerTabPageModel an internal CancelPendingAsync (store the in-flight
decompile task, cancel its CTS, return it) and DockWorkspace a
CancelPendingOperationsAsync that fans that across every document tab -- both
legitimate shutdown hygiene too. ResetAppState.AfterTest now cancels and pumps
the dispatcher until the work unwinds before the next test boots.

This stops the random rotation of the failing test (the victims are now stable
and diagnosable); two order-dependent failures remain (About-tab duplication and
a nested-namespace tree race) that need their own fixes -- they still pass in
isolation.
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
4d99dea6c5
  1. 37
      ILSpy.Tests/ResetAppStateAttribute.cs
  2. 20
      ILSpy/Docking/DockWorkspace.cs
  3. 17
      ILSpy/TextView/DecompilerTabPageModel.cs

37
ILSpy.Tests/ResetAppStateAttribute.cs

@ -19,6 +19,8 @@ @@ -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 @@ -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 @@ -100,6 +109,32 @@ public sealed class ResetAppStateAttribute : Attribute, ITestAction
Dispatcher.UIThread.RunJobs();
}
static void DrainPendingWork()
{
Task quiesce;
try
{
quiesce = AppComposition.Current.GetExport<global::ILSpy.Docking.DockWorkspace>().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)

20
ILSpy/Docking/DockWorkspace.cs

@ -426,6 +426,26 @@ namespace ILSpy.Docking @@ -426,6 +426,26 @@ namespace ILSpy.Docking
}
}
/// <summary>
/// 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.
/// </summary>
internal Task CancelPendingOperationsAsync()
{
if (Layout is not IDockable root)
return Task.CompletedTask;
var pending = FlattenDocumentDocks(root)
.SelectMany(dock => dock.VisibleDockables?.OfType<ContentTabPage>() ?? Enumerable.Empty<ContentTabPage>())
.Select(tab => tab.Content)
.OfType<TextView.DecompilerTabPageModel>()
.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.

17
ILSpy/TextView/DecompilerTabPageModel.cs

@ -50,6 +50,9 @@ namespace ILSpy.TextView @@ -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 @@ -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);
}
/// <summary>
/// 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).
/// </summary>
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

Loading…
Cancel
Save