mirror of https://github.com/icsharpcode/ILSpy.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
444 lines
12 KiB
444 lines
12 KiB
// Copyright (c) 2026 Siegfried Pammer |
|
// |
|
// 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; |
|
using System.Collections.Concurrent; |
|
using System.Runtime.ExceptionServices; |
|
using System.Threading; |
|
|
|
using AwesomeAssertions; |
|
|
|
using ICSharpCode.ILSpyX.TreeView; |
|
|
|
using NUnit.Framework; |
|
|
|
namespace ICSharpCode.ILSpy.Tests.Controls; |
|
|
|
[TestFixture] |
|
public class TreeThreadAffinityTests |
|
{ |
|
sealed class TestNode : SharpTreeNode |
|
{ |
|
readonly string text; |
|
public TestNode(string text) => this.text = text; |
|
public override object Text => text; |
|
public override string ToString() => text; |
|
} |
|
|
|
/// <summary> |
|
/// A lazy node that records where and how often its children were built, plus an optional hook |
|
/// so a test can drive whatever LoadChildren would do in the app. |
|
/// </summary> |
|
sealed class LazyTestNode : SharpTreeNode |
|
{ |
|
readonly string text; |
|
|
|
public LazyTestNode(string text) |
|
{ |
|
this.text = text; |
|
LazyLoading = true; |
|
} |
|
|
|
public Action<LazyTestNode>? OnLoadChildren { get; set; } |
|
public Thread? LoadChildrenThread { get; private set; } |
|
public int LoadChildrenCount { get; private set; } |
|
|
|
protected override void LoadChildren() |
|
{ |
|
LoadChildrenThread = Thread.CurrentThread; |
|
LoadChildrenCount++; |
|
OnLoadChildren?.Invoke(this); |
|
} |
|
|
|
public override object Text => text; |
|
public override string ToString() => text; |
|
} |
|
|
|
/// <summary> |
|
/// A stand-in for the UI thread: a thread with a work queue, whose <see cref="Invoke"/> has the |
|
/// same contract as the host dispatcher's - run the action there, block until it is done, and |
|
/// run it inline when the caller already is that thread. |
|
/// </summary> |
|
sealed class OwnerThread : IDisposable |
|
{ |
|
readonly BlockingCollection<Action> queue = new(); |
|
readonly Thread thread; |
|
int invokeCount; |
|
|
|
public OwnerThread() |
|
{ |
|
thread = new Thread(() => { |
|
foreach (var work in queue.GetConsumingEnumerable()) |
|
work(); |
|
}) { Name = "affinity-test-owner", IsBackground = true }; |
|
thread.Start(); |
|
} |
|
|
|
public Thread Thread => thread; |
|
|
|
/// <summary>Number of calls that actually had to be marshalled.</summary> |
|
public int InvokeCount => invokeCount; |
|
|
|
public void Invoke(Action action) |
|
{ |
|
if (Thread.CurrentThread == thread) |
|
{ |
|
action(); |
|
return; |
|
} |
|
Interlocked.Increment(ref invokeCount); |
|
ExceptionDispatchInfo? failure = null; |
|
using var done = new ManualResetEventSlim(); |
|
queue.Add(() => { |
|
try |
|
{ |
|
action(); |
|
} |
|
catch (Exception ex) |
|
{ |
|
failure = ExceptionDispatchInfo.Capture(ex); |
|
} |
|
finally |
|
{ |
|
done.Set(); |
|
} |
|
}); |
|
done.Wait(); |
|
failure?.Throw(); |
|
} |
|
|
|
public void Dispose() |
|
{ |
|
queue.CompleteAdding(); |
|
thread.Join(); |
|
queue.Dispose(); |
|
} |
|
} |
|
|
|
bool oldFailFast; |
|
string? oldLogFilePath; |
|
|
|
[SetUp] |
|
public void SetUp() |
|
{ |
|
oldFailFast = TreeThreadAffinity.FailFast; |
|
oldLogFilePath = TreeThreadAffinity.LogFilePath; |
|
// Tests must not append to whatever log an exploratory session is collecting, and must not |
|
// inherit the mode from the environment: the ones that need a throw opt in individually. |
|
TreeThreadAffinity.LogFilePath = null; |
|
TreeThreadAffinity.FailFast = false; |
|
TreeThreadAffinity.Clear(); |
|
} |
|
|
|
[TearDown] |
|
public void TearDown() |
|
{ |
|
TreeThreadAffinity.FailFast = oldFailFast; |
|
TreeThreadAffinity.LogFilePath = oldLogFilePath; |
|
TreeThreadAffinity.Clear(); |
|
} |
|
|
|
/// <summary> |
|
/// Runs <paramref name="action"/> to completion on a dedicated thread and returns whatever it |
|
/// threw. Join() makes this deterministic: no sleeps, no polling. |
|
/// </summary> |
|
static Exception? RunOnOtherThread(Action action) |
|
{ |
|
Exception? error = null; |
|
var thread = new Thread(() => { |
|
try |
|
{ |
|
action(); |
|
} |
|
catch (Exception ex) |
|
{ |
|
error = ex; |
|
} |
|
}) { Name = "affinity-test-worker", IsBackground = true }; |
|
thread.Start(); |
|
thread.Join(); |
|
return error; |
|
} |
|
|
|
[Test] |
|
public void EnsureLazyChildrenFromNonOwningThread_LoadsOnTheOwningThread() |
|
{ |
|
using var owner = new OwnerThread(); |
|
var root = new LazyTestNode("root"); |
|
root.SetOwner(owner.Thread, owner.Invoke); |
|
|
|
root.EnsureLazyChildren(); |
|
|
|
root.LoadChildrenThread.Should().BeSameAs(owner.Thread); |
|
root.LoadChildrenCount.Should().Be(1); |
|
owner.InvokeCount.Should().Be(1); |
|
} |
|
|
|
[Test] |
|
public void EnsureLazyChildrenOnUnownedTree_LoadsOnTheCallingThread() |
|
{ |
|
var root = new LazyTestNode("root"); |
|
|
|
var error = RunOnOtherThread(root.EnsureLazyChildren); |
|
|
|
error.Should().BeNull(); |
|
root.LoadChildrenThread.Should().NotBeNull(); |
|
root.LoadChildrenThread!.Name.Should().Be("affinity-test-worker"); |
|
root.LoadChildrenCount.Should().Be(1); |
|
} |
|
|
|
[Test] |
|
public void EnsureLazyChildrenOnTheOwningThread_DoesNotMarshalAndLoadsOnce() |
|
{ |
|
using var owner = new OwnerThread(); |
|
var root = new LazyTestNode("root"); |
|
root.SetOwner(owner.Thread, owner.Invoke); |
|
|
|
owner.Invoke(() => { |
|
root.EnsureLazyChildren(); |
|
root.EnsureLazyChildren(); |
|
}); |
|
|
|
// One marshalled call: the test thread getting onto the owner. Nothing inside it needed a |
|
// second hop, which is what keeps a blocking invoke from deadlocking on itself. |
|
owner.InvokeCount.Should().Be(1); |
|
root.LoadChildrenCount.Should().Be(1); |
|
root.LoadChildrenThread.Should().BeSameAs(owner.Thread); |
|
} |
|
|
|
[Test] |
|
public void NestedEnsureLazyChildrenFromNonOwningThread_MarshalsOnlyOnce() |
|
{ |
|
// The shape AssemblyReferenceReferencedTypesTreeNode has: loading a node's children |
|
// realises a child node's children from inside LoadChildren. |
|
using var owner = new OwnerThread(); |
|
var root = new LazyTestNode("root"); |
|
var child = new LazyTestNode("child"); |
|
root.OnLoadChildren = node => { |
|
node.Children.Add(child); |
|
child.EnsureLazyChildren(); |
|
}; |
|
root.SetOwner(owner.Thread, owner.Invoke); |
|
|
|
root.EnsureLazyChildren(); |
|
|
|
owner.InvokeCount.Should().Be(1); |
|
root.LoadChildrenThread.Should().BeSameAs(owner.Thread); |
|
child.LoadChildrenThread.Should().BeSameAs(owner.Thread); |
|
child.LoadChildrenCount.Should().Be(1); |
|
} |
|
|
|
#if DEBUG |
|
|
|
[Test] |
|
public void EnsureLazyChildrenFromNonOwningThread_ProducesNoViolation() |
|
{ |
|
TreeThreadAffinity.FailFast = true; |
|
using var owner = new OwnerThread(); |
|
var root = new LazyTestNode("root"); |
|
root.OnLoadChildren = node => node.Children.Add(new TestNode("child")); |
|
root.SetOwner(owner.Thread, owner.Invoke); |
|
|
|
root.EnsureLazyChildren(); |
|
|
|
TreeThreadAffinity.Violations.Should().BeEmpty(); |
|
} |
|
|
|
[Test] |
|
public void FailFastViolation_IsRecordedEvenWhenTheThrowIsSwallowed() |
|
{ |
|
// The app's background decompile wraps every node in catch (Exception), so a fail-fast |
|
// throw never reaches the test host. The collector has to stay authoritative no matter who |
|
// catches, or a fail-fast suite run comes back green and means nothing. |
|
TreeThreadAffinity.FailFast = true; |
|
var root = new TestNode("root"); |
|
root.SetOwner(); |
|
|
|
var error = RunOnOtherThread(() => { |
|
try |
|
{ |
|
root.Children.Add(new TestNode("child")); |
|
} |
|
catch (Exception) |
|
{ |
|
} |
|
}); |
|
|
|
error.Should().BeNull(); |
|
TreeThreadAffinity.Violations.Should().ContainSingle() |
|
.Which.Operation.Should().Contain("Children.Add"); |
|
} |
|
|
|
[Test] |
|
public void ChildrenAddFromNonOwningThread_IsReported() |
|
{ |
|
TreeThreadAffinity.FailFast = true; |
|
var root = new TestNode("root"); |
|
root.SetOwner(); |
|
|
|
var error = RunOnOtherThread(() => root.Children.Add(new TestNode("child"))); |
|
|
|
error.Should().BeOfType<InvalidOperationException>(); |
|
error!.Message.Should().Contain("Children.Add").And.Contain("\"root\""); |
|
} |
|
|
|
[Test] |
|
public void IsExpandedFromNonOwningThread_IsReported() |
|
{ |
|
TreeThreadAffinity.FailFast = true; |
|
var root = new TestNode("root"); |
|
root.Children.Add(new TestNode("child")); |
|
root.SetOwner(); |
|
|
|
var error = RunOnOtherThread(() => root.IsExpanded = true); |
|
|
|
error.Should().BeOfType<InvalidOperationException>(); |
|
error!.Message.Should().Contain(nameof(SharpTreeNode.IsExpanded)); |
|
} |
|
|
|
[Test] |
|
public void IsHiddenFromNonOwningThread_IsReported() |
|
{ |
|
TreeThreadAffinity.FailFast = true; |
|
var root = new TestNode("root"); |
|
var child = new TestNode("child"); |
|
root.Children.Add(child); |
|
root.SetOwner(); |
|
|
|
var error = RunOnOtherThread(() => child.IsHidden = true); |
|
|
|
error.Should().BeOfType<InvalidOperationException>(); |
|
error!.Message.Should().Contain(nameof(SharpTreeNode.IsHidden)); |
|
} |
|
|
|
[Test] |
|
public void UnownedTree_IsNotChecked() |
|
{ |
|
TreeThreadAffinity.FailFast = true; |
|
var root = new TestNode("root"); |
|
|
|
var error = RunOnOtherThread(() => { |
|
root.Children.Add(new TestNode("child")); |
|
root.IsExpanded = true; |
|
}); |
|
|
|
error.Should().BeNull(); |
|
TreeThreadAffinity.Violations.Should().BeEmpty(); |
|
} |
|
|
|
[Test] |
|
public void SubtreeBuiltOffThreadThenPublishedByOwner_IsNotReported() |
|
{ |
|
// The pattern the analyzers use: assemble a subtree on a worker, hand it to the owning |
|
// thread, attach it there. |
|
TreeThreadAffinity.FailFast = true; |
|
var root = new TestNode("root"); |
|
root.SetOwner(); |
|
|
|
TestNode? built = null; |
|
var error = RunOnOtherThread(() => { |
|
built = new TestNode("built"); |
|
built.Children.Add(new TestNode("grandchild")); |
|
built.IsExpanded = true; |
|
}); |
|
|
|
error.Should().BeNull(); |
|
root.Children.Add(built!); |
|
TreeThreadAffinity.Violations.Should().BeEmpty(); |
|
} |
|
|
|
[Test] |
|
public void OwnershipIsInheritedByChildrenAttachedLater() |
|
{ |
|
TreeThreadAffinity.FailFast = true; |
|
var root = new TestNode("root"); |
|
root.SetOwner(); |
|
var child = new TestNode("child"); |
|
root.Children.Add(child); |
|
|
|
var error = RunOnOtherThread(() => child.Children.Add(new TestNode("grandchild"))); |
|
|
|
error.Should().BeOfType<InvalidOperationException>(); |
|
error!.Message.Should().Contain("\"child\""); |
|
} |
|
|
|
[Test] |
|
public void AttachingSubtreeOwnedByAnotherThread_IsReportedOnceThenInherits() |
|
{ |
|
var root = new TestNode("root"); |
|
root.SetOwner(); |
|
var foreign = new TestNode("foreign"); |
|
RunOnOtherThread(() => foreign.SetOwner()).Should().BeNull(); |
|
|
|
root.Children.Add(foreign); |
|
|
|
TreeThreadAffinity.Violations.Should().ContainSingle() |
|
.Which.Operation.Should().Contain("owned by another thread"); |
|
|
|
// The conflicting owner is dropped, so the subtree now inherits the root's owner: further |
|
// mutation from the owning thread is clean, and from any other thread is a violation. |
|
TreeThreadAffinity.Clear(); |
|
foreign.Children.Add(new TestNode("grandchild")); |
|
TreeThreadAffinity.Violations.Should().BeEmpty(); |
|
|
|
TreeThreadAffinity.FailFast = true; |
|
RunOnOtherThread(() => foreign.Children.Add(new TestNode("other"))) |
|
.Should().BeOfType<InvalidOperationException>(); |
|
} |
|
|
|
[Test] |
|
public void RepeatedViolationsFromOneCallSite_AreDeduplicatedAndCounted() |
|
{ |
|
var root = new TestNode("root"); |
|
root.SetOwner(); |
|
|
|
var error = RunOnOtherThread(() => { |
|
for (int i = 0; i < 5; i++) |
|
root.Children.Add(new TestNode("child" + i)); |
|
}); |
|
|
|
error.Should().BeNull(); |
|
var violation = TreeThreadAffinity.Violations.Should().ContainSingle().Subject; |
|
violation.Count.Should().Be(5); |
|
violation.StackTrace.Should().Contain(nameof(RepeatedViolationsFromOneCallSite_AreDeduplicatedAndCounted)); |
|
violation.ActualThread.Should().Contain("affinity-test-worker"); |
|
} |
|
|
|
[Test] |
|
public void OwnershipHandoffFromNonOwningThread_IsReported() |
|
{ |
|
TreeThreadAffinity.FailFast = true; |
|
var root = new TestNode("root"); |
|
root.SetOwner(); |
|
|
|
var error = RunOnOtherThread(() => root.SetOwner()); |
|
|
|
error.Should().BeOfType<InvalidOperationException>(); |
|
error!.Message.Should().Contain(nameof(SharpTreeNode.SetOwner)); |
|
} |
|
|
|
#else |
|
|
|
[Test] |
|
public void ThreadAffinityChecksAreDebugOnly() |
|
{ |
|
Assert.Ignore("SharpTreeNode thread-affinity checking is compiled out in release builds."); |
|
} |
|
|
|
#endif |
|
}
|
|
|