Browse Source

Await debug-step replay decompiles to fix flaky UI tests

The Debug Steps "show state before/after" replay re-decompiles the active
tab; the headless UI tests inferred completion by polling IsDecompiling +
Text, which can return on stale state or hit the 60s wait deadline when
that shared signal races under CI load (the observed intermittent CI
timeout). RestartDecompileWithStepLimit now returns the decompile Task so
the replay completion can be awaited deterministically; the tests await it
instead of polling. The production IsDecompiling reset is unchanged -- the
last decompile's finally always resets it; the race was only in the test's
completion inference.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3847/head
Siegfried Pammer 1 week ago committed by Siegfried Pammer
parent
commit
bf2f57106a
  1. 12
      ILSpy.Tests/Views/DebugStepsTests.cs
  2. 20
      ILSpy/TextView/DecompilerTabPageModel.cs

12
ILSpy.Tests/Views/DebugStepsTests.cs

@ -172,15 +172,13 @@ public class DebugStepsTests
var replayStep = transformGroupWithChanges.Children.First(); var replayStep = transformGroupWithChanges.Children.First();
var tab = vm.DockWorkspace.ActiveDecompilerTab!; var tab = vm.DockWorkspace.ActiveDecompilerTab!;
tab.RestartDecompileWithStepLimit(replayStep.BeginStep, isDebug: false, replayStep.BeginStep); await tab.RestartDecompileWithStepLimit(replayStep.BeginStep, isDebug: false, replayStep.BeginStep);
tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
tab.Text.Should().NotBeNullOrWhiteSpace("C# replay before a selected AST mutation step must still emit code"); tab.Text.Should().NotBeNullOrWhiteSpace("C# replay before a selected AST mutation step must still emit code");
tab.DebugStepHighlight.Should().NotBeNull("C# replay before a selected AST mutation step must locate the changed node"); tab.DebugStepHighlight.Should().NotBeNull("C# replay before a selected AST mutation step must locate the changed node");
debugStepsVm.Steps.Should().BeSameAs(collectedSteps, debugStepsVm.Steps.Should().BeSameAs(collectedSteps,
"a step-limited C# replay must not replace the full step tree shown by the pane"); "a step-limited C# replay must not replace the full step tree shown by the pane");
tab.RestartDecompileWithStepLimit(replayStep.EndStep, isDebug: false, replayStep.BeginStep); await tab.RestartDecompileWithStepLimit(replayStep.EndStep, isDebug: false, replayStep.BeginStep);
tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
tab.Text.Should().NotBeNullOrWhiteSpace("C# replay after a selected AST mutation step must still emit code"); tab.Text.Should().NotBeNullOrWhiteSpace("C# replay after a selected AST mutation step must still emit code");
tab.DebugStepHighlight.Should().NotBeNull("C# replay after a selected AST mutation step must locate the changed node"); tab.DebugStepHighlight.Should().NotBeNull("C# replay after a selected AST mutation step must locate the changed node");
debugStepsVm.Steps.Should().BeSameAs(collectedSteps, debugStepsVm.Steps.Should().BeSameAs(collectedSteps,
@ -226,15 +224,13 @@ public class DebugStepsTests
var collectedSteps = debugStepsVm.Steps; var collectedSteps = debugStepsVm.Steps;
var tab = vm.DockWorkspace.ActiveDecompilerTab!; var tab = vm.DockWorkspace.ActiveDecompilerTab!;
tab.RestartDecompileWithStepLimit(replayStep!.BeginStep, isDebug: false, replayStep.BeginStep); await tab.RestartDecompileWithStepLimit(replayStep!.BeginStep, isDebug: false, replayStep.BeginStep);
tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
tab.Text.Should().NotBeNullOrWhiteSpace("ILAst replay before a selected step must still emit IL"); tab.Text.Should().NotBeNullOrWhiteSpace("ILAst replay before a selected step must still emit IL");
tab.DebugStepHighlight.Should().NotBeNull("ILAst replay before a selected step must locate the changed instruction"); tab.DebugStepHighlight.Should().NotBeNull("ILAst replay before a selected step must locate the changed instruction");
debugStepsVm.Steps.Should().BeSameAs(collectedSteps, debugStepsVm.Steps.Should().BeSameAs(collectedSteps,
"a step-limited ILAst replay must not replace the full step tree shown by the pane"); "a step-limited ILAst replay must not replace the full step tree shown by the pane");
tab.RestartDecompileWithStepLimit(replayStep.EndStep, isDebug: false, replayStep.BeginStep); await tab.RestartDecompileWithStepLimit(replayStep.EndStep, isDebug: false, replayStep.BeginStep);
tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
tab.Text.Should().NotBeNullOrWhiteSpace("ILAst replay after a selected step must still emit IL"); tab.Text.Should().NotBeNullOrWhiteSpace("ILAst replay after a selected step must still emit IL");
tab.DebugStepHighlight.Should().NotBeNull("ILAst replay after a selected step must locate the changed instruction"); tab.DebugStepHighlight.Should().NotBeNull("ILAst replay after a selected step must locate the changed instruction");

20
ILSpy/TextView/DecompilerTabPageModel.cs

@ -401,12 +401,12 @@ namespace ICSharpCode.ILSpy.TextView
/// is <see cref="int.MaxValue"/>). <paramref name="isDebug"/> toggles the transforms' /// is <see cref="int.MaxValue"/>). <paramref name="isDebug"/> toggles the transforms'
/// verbose-debug emission. No-op when there's nothing currently being decompiled. /// verbose-debug emission. No-op when there's nothing currently being decompiled.
/// </summary> /// </summary>
public void RestartDecompileWithStepLimit(int stepLimit, bool isDebug, int? highlightStep = null) public Task RestartDecompileWithStepLimit(int stepLimit, bool isDebug, int? highlightStep = null)
{ {
pendingStepLimit = stepLimit; pendingStepLimit = stepLimit;
pendingHighlightStep = highlightStep; pendingHighlightStep = highlightStep;
pendingIsDebug = isDebug; pendingIsDebug = isDebug;
StartDecompile(); return StartDecompile();
} }
/// <summary>Re-runs the current decompile with a larger output-length limit (the "Display code /// <summary>Re-runs the current decompile with a larger output-length limit (the "Display code
@ -463,16 +463,20 @@ namespace ICSharpCode.ILSpy.TextView
ICSharpCode.ILSpy.Commands.SaveCodeHelper.SaveNodeAsync(node, languageService, dockWorkspace).HandleExceptions(); ICSharpCode.ILSpy.Commands.SaveCodeHelper.SaveNodeAsync(node, languageService, dockWorkspace).HandleExceptions();
} }
// Fire-and-forget wrapper around DecompileAsync that observes the resulting Task. // Starts a decompile and returns the Task that completes once the output has been applied,
// Without this, exceptions raised by the dispatched property setters (e.g. the // so callers (e.g. the Debug Steps replay, and tests) can await completion deterministically
// PropertyChanged subscribers in DecompilerTextView) become UnobservedTaskException // instead of polling IsDecompiling/Text. A fault-observing continuation is attached so that
// faults that the finalizer thread rethrows much later, hiding the originating bug. // exceptions raised by the dispatched property setters (e.g. the PropertyChanged subscribers
void StartDecompile() // in DecompilerTextView) don't become UnobservedTaskException faults the finalizer thread
// rethrows much later, hiding the originating bug.
Task StartDecompile()
{ {
pendingDecompile = DecompileAsync().ContinueWith(t => { var decompile = DecompileAsync();
pendingDecompile = decompile.ContinueWith(t => {
if (t.Exception is { } ex) if (t.Exception is { } ex)
System.Diagnostics.Debug.WriteLine($"DecompileAsync faulted: {ex.Flatten()}"); System.Diagnostics.Debug.WriteLine($"DecompileAsync faulted: {ex.Flatten()}");
}, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); }, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);
return decompile;
} }
/// <summary> /// <summary>

Loading…
Cancel
Save