mirror of https://github.com/icsharpcode/ILSpy.git
11 changed files with 1064 additions and 10 deletions
@ -0,0 +1,298 @@
@@ -0,0 +1,298 @@
|
||||
// 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.Collections.Generic; |
||||
using System.Collections.Specialized; |
||||
using System.Diagnostics; |
||||
using System.IO; |
||||
using System.Linq; |
||||
using System.Text; |
||||
using System.Threading; |
||||
|
||||
namespace ICSharpCode.ILSpyX.TreeView |
||||
{ |
||||
// Thread affinity of the tree model.
|
||||
//
|
||||
// The model is not thread-safe. TreeFlattener.Count and SharpTreeNode.GetNodeByVisibleIndex
|
||||
// both read the augmented 'totalListLength' fields, so a structural mutation racing a read can
|
||||
// hand out an index for a node that no longer exists; issue #3290 is a NullReferenceException
|
||||
// inside GetNodeByVisibleIndex that is unreachable single-threaded. The rule is that a tree
|
||||
// displayed by a UI is mutated only from that UI's thread, but ICSharpCode.ILSpyX is
|
||||
// host-agnostic and must not name a dispatcher, so ownership is stated explicitly instead:
|
||||
// a host calls SetOwner() on the root of a tree it takes over, passing the thread and the way
|
||||
// to get onto it.
|
||||
//
|
||||
// Ownership carries the rule two ways. EnsureLazyChildren uses the invoke delegate to move
|
||||
// itself onto the owning thread, so a caller cannot get it wrong; and, in debug builds, every
|
||||
// other structural mutation is verified against the owning thread so a call site that bypasses
|
||||
// the rule is named instead of silently corrupting the flat list.
|
||||
partial class SharpTreeNode |
||||
{ |
||||
Thread? owner; |
||||
Action<Action>? ownerInvoke; |
||||
|
||||
/// <summary>
|
||||
/// The nearest node on the model-parent chain that carries an explicit owner, or null when
|
||||
/// nothing on the chain has been claimed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resolving the owner by walking up instead of stamping every node gives the propagation
|
||||
/// rules for free:
|
||||
/// <list type="bullet">
|
||||
/// <item>Ownership covers the whole subtree below the node it was set on, so a single call on
|
||||
/// the root of a displayed tree protects everything in it.</item>
|
||||
/// <item>Children added later inherit it, with no bookkeeping at insertion time.</item>
|
||||
/// <item>A subtree built on a background thread carries no owner and is therefore unchecked
|
||||
/// while it is being built. It inherits the owner the instant it is attached below an owned
|
||||
/// parent - and that attachment is itself a mutation of the owned tree, so it is checked.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
SharpTreeNode? OwnerNode { |
||||
get { |
||||
for (SharpTreeNode? node = this; node != null; node = node.modelParent) |
||||
{ |
||||
if (node.owner != null) |
||||
return node; |
||||
} |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
Thread? EffectiveOwner => OwnerNode?.owner; |
||||
|
||||
/// <summary>
|
||||
/// Declares <paramref name="owner"/> as the only thread allowed to structurally mutate this
|
||||
/// node and its subtree.
|
||||
/// </summary>
|
||||
/// <param name="invoke">
|
||||
/// Runs an action on <paramref name="owner"/> and blocks until it has completed - the host's
|
||||
/// dispatcher invoke. Supplying it lets <see cref="EnsureLazyChildren"/> marshal itself
|
||||
/// instead of every caller having to know it must. Null leaves the tree unmarshalled, which
|
||||
/// only makes sense for a tree no UI is displaying.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Re-owning is allowed - handing a tree over is exactly what this exists for - but the
|
||||
/// handoff must be performed by the thread that currently owns the tree, because a
|
||||
/// background thread taking ownership of a live tree away from the UI is the very race the
|
||||
/// affinity check hunts for.
|
||||
/// </remarks>
|
||||
public void SetOwner(Thread owner, Action<Action>? invoke = null) |
||||
{ |
||||
VerifyAccess(nameof(SetOwner)); |
||||
this.owner = owner; |
||||
this.ownerInvoke = invoke; |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Declares the calling thread as the only thread allowed to structurally mutate this node
|
||||
/// and its subtree, without a way to marshal onto it.
|
||||
/// </summary>
|
||||
public void SetOwner() => SetOwner(Thread.CurrentThread); |
||||
|
||||
/// <summary>
|
||||
/// Reports a violation if the calling thread is not the effective owner of this node.
|
||||
/// </summary>
|
||||
[Conditional("DEBUG")] |
||||
internal void VerifyAccess(string operation) |
||||
{ |
||||
#if DEBUG
|
||||
Thread? expected = EffectiveOwner; |
||||
if (expected == null || expected == Thread.CurrentThread) |
||||
return; |
||||
TreeThreadAffinity.Report(this, operation, expected); |
||||
#endif
|
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Verifies a pending change to this node's <see cref="Children"/> collection.
|
||||
/// </summary>
|
||||
[Conditional("DEBUG")] |
||||
internal void VerifyChildrenChange(NotifyCollectionChangedEventArgs e) |
||||
{ |
||||
#if DEBUG
|
||||
VerifyAccess("Children." + e.Action); |
||||
if (e.NewItems == null) |
||||
return; |
||||
// An already-owned subtree being attached below a differently-owned parent would keep its
|
||||
// own owner, so the two halves of one displayed tree would demand two different threads.
|
||||
// That is a mistake worth naming, but only once: the incoming owner is dropped afterwards
|
||||
// so the subtree inherits the parent's, instead of reporting again on every later mutation.
|
||||
Thread? parentOwner = EffectiveOwner; |
||||
foreach (SharpTreeNode node in e.NewItems) |
||||
{ |
||||
if (node.owner != null && node.owner != parentOwner) |
||||
{ |
||||
TreeThreadAffinity.Report(node, "attach of a subtree owned by another thread", node.owner); |
||||
node.owner = null; |
||||
node.ownerInvoke = null; |
||||
} |
||||
} |
||||
#endif
|
||||
} |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Collects <see cref="SharpTreeNode"/> thread-affinity violations. Nothing here is reached in
|
||||
/// release builds: every caller is <c>[Conditional("DEBUG")]</c>.
|
||||
/// </summary>
|
||||
public static class TreeThreadAffinity |
||||
{ |
||||
/// <summary>
|
||||
/// Environment variable selecting the log file. Defaults to ILSpy.TreeAffinity.log in the
|
||||
/// temp directory; set it to an empty string to disable file logging.
|
||||
/// </summary>
|
||||
public const string LogFileVariable = "ILSPY_TREE_AFFINITY_LOG"; |
||||
|
||||
/// <summary>
|
||||
/// Environment variable enabling <see cref="FailFast"/> at startup (set it to 1).
|
||||
/// </summary>
|
||||
public const string FailFastVariable = "ILSPY_TREE_AFFINITY_FAILFAST"; |
||||
|
||||
static readonly ConcurrentDictionary<string, TreeThreadAffinityViolation> violations = new(); |
||||
static readonly object logLock = new(); |
||||
|
||||
/// <summary>
|
||||
/// When true, a violation throws after being recorded, so a debugger stops at the offending
|
||||
/// frame. It is not what makes a violation observable - <see cref="Violations"/> is recorded
|
||||
/// either way, because the throw may well be swallowed by a caller that catches Exception.
|
||||
/// An exploratory session over a large corpus leaves this off so one bad call site does not
|
||||
/// interfere with the rest of the run.
|
||||
/// </summary>
|
||||
public static bool FailFast { get; set; } = Environment.GetEnvironmentVariable(FailFastVariable) == "1"; |
||||
|
||||
/// <summary>
|
||||
/// The file violations are appended to, or null when file logging is disabled.
|
||||
/// </summary>
|
||||
public static string? LogFilePath { get; set; } = ResolveLogFilePath(); |
||||
|
||||
static string? ResolveLogFilePath() |
||||
{ |
||||
string? configured = Environment.GetEnvironmentVariable(LogFileVariable); |
||||
if (configured == null) |
||||
return Path.Combine(Path.GetTempPath(), "ILSpy.TreeAffinity.log"); |
||||
return configured.Length == 0 ? null : configured; |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// The distinct violating call sites recorded so far, most frequent first.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<TreeThreadAffinityViolation> Violations |
||||
=> violations.Values.OrderByDescending(v => v.Count).ToList(); |
||||
|
||||
public static void Clear() => violations.Clear(); |
||||
|
||||
internal static void Report(SharpTreeNode node, string operation, Thread expected) |
||||
{ |
||||
// Skip Report and the [Conditional] wrapper that called it, so frame 0 is the mutation.
|
||||
string stackTrace = new StackTrace(2, fNeedFileInfo: true).ToString(); |
||||
var violation = new TreeThreadAffinityViolation(Describe(node), operation, expected, Thread.CurrentThread, stackTrace); |
||||
Debug.WriteLine(violation.ToString()); |
||||
// Deduplicate by call site: a mutation inside a loop must not produce one entry per
|
||||
// iteration. The first occurrence is written through immediately so a long-running
|
||||
// session can be inspected without stopping the process; repeats only bump the count.
|
||||
var recorded = violations.GetOrAdd(stackTrace, violation); |
||||
if (recorded.Hit() == 1) |
||||
AppendToLog(recorded.ToString()); |
||||
// Recorded first, thrown second. The throw is only a convenience for stopping a
|
||||
// debugger at the offending frame; it is not the record. Tree mutation happens inside
|
||||
// callers that catch Exception (the background decompile writes the failure into the
|
||||
// text view), so a throw alone would be swallowed and a fail-fast run would come back
|
||||
// green while violating. Violations is what a test or a session asserts on.
|
||||
if (FailFast) |
||||
throw new InvalidOperationException(violation.ToString()); |
||||
} |
||||
|
||||
static string Describe(SharpTreeNode node) |
||||
{ |
||||
string text; |
||||
try |
||||
{ |
||||
// ToString() is what the model itself already uses for diagnostics. It can still fail
|
||||
// here: this runs on a thread the node did not expect, which is the whole problem.
|
||||
text = node.ToString() ?? "<null>"; |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
text = "<ToString() threw " + ex.GetType().Name + ">"; |
||||
} |
||||
return node.GetType().FullName + " \"" + text + "\""; |
||||
} |
||||
|
||||
static void AppendToLog(string text) |
||||
{ |
||||
string? path = LogFilePath; |
||||
if (path == null) |
||||
return; |
||||
try |
||||
{ |
||||
lock (logLock) |
||||
{ |
||||
File.AppendAllText(path, text + Environment.NewLine + Environment.NewLine); |
||||
} |
||||
} |
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) |
||||
{ |
||||
// A diagnostic that cannot write its log must not take the session down with it.
|
||||
} |
||||
} |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// One distinct call site that mutated a tree from a thread other than its owner.
|
||||
/// </summary>
|
||||
public sealed class TreeThreadAffinityViolation |
||||
{ |
||||
int count; |
||||
|
||||
internal TreeThreadAffinityViolation(string node, string operation, Thread expected, Thread actual, string stackTrace) |
||||
{ |
||||
this.Node = node; |
||||
this.Operation = operation; |
||||
this.ExpectedThread = DescribeThread(expected); |
||||
this.ActualThread = DescribeThread(actual); |
||||
this.StackTrace = stackTrace; |
||||
} |
||||
|
||||
public string Node { get; } |
||||
public string Operation { get; } |
||||
public string ExpectedThread { get; } |
||||
public string ActualThread { get; } |
||||
public string StackTrace { get; } |
||||
|
||||
/// <summary>Number of times this call site has been hit.</summary>
|
||||
public int Count => count; |
||||
|
||||
internal int Hit() => Interlocked.Increment(ref count); |
||||
|
||||
static string DescribeThread(Thread thread) |
||||
=> thread.ManagedThreadId + " \"" + (thread.Name ?? "<unnamed>") + "\""; |
||||
|
||||
public override string ToString() |
||||
{ |
||||
var b = new StringBuilder(); |
||||
b.Append("SharpTreeNode thread affinity violation: ").Append(Operation).AppendLine(); |
||||
b.Append(" node: ").AppendLine(Node); |
||||
b.Append(" expected: thread ").AppendLine(ExpectedThread); |
||||
b.Append(" actual: thread ").AppendLine(ActualThread); |
||||
b.Append(StackTrace); |
||||
return b.ToString(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
|
||||
// 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 AwesomeAssertions; |
||||
|
||||
using ICSharpCode.ILSpyX.TreeView; |
||||
|
||||
using NUnit.Framework; |
||||
|
||||
namespace ICSharpCode.ILSpy.Tests.Controls; |
||||
|
||||
[TestFixture] |
||||
public class FlatListTreeNodeTests |
||||
{ |
||||
sealed class TestNode : SharpTreeNode |
||||
{ |
||||
readonly string text; |
||||
public TestNode(string text) => this.text = text; |
||||
public override object Text => text; |
||||
public override string ToString() => text; |
||||
} |
||||
|
||||
[Test] |
||||
public void GetNodeByVisibleIndex_WalkingPastTheEnd_ThrowsNamingIndexAndLength() |
||||
{ |
||||
var root = new TestNode("root"); |
||||
root.Children.Add(new TestNode("child")); |
||||
root.IsExpanded = true; |
||||
var listRoot = root.GetListRoot(); |
||||
listRoot.GetTotalListLength().Should().Be(2); |
||||
|
||||
// A restructure that happened under a reader leaves the augmented length disagreeing with
|
||||
// the structure it describes: the length says there is a node at this index, the descent
|
||||
// runs out of nodes before reaching it.
|
||||
listRoot.totalListLength = 5; |
||||
|
||||
var error = Assert.Throws<InvalidOperationException>( |
||||
() => SharpTreeNode.GetNodeByVisibleIndex(listRoot, 4)); |
||||
|
||||
error!.Message.Should().Contain("4").And.Contain("5"); |
||||
} |
||||
|
||||
[Test] |
||||
public void GetNodeByVisibleIndex_WithinTheList_ReturnsTheNodeAtThatIndex() |
||||
{ |
||||
var root = new TestNode("root"); |
||||
var child = new TestNode("child"); |
||||
root.Children.Add(child); |
||||
root.IsExpanded = true; |
||||
var listRoot = root.GetListRoot(); |
||||
|
||||
Assert.That(SharpTreeNode.GetNodeByVisibleIndex(listRoot, 0), Is.SameAs(root)); |
||||
Assert.That(SharpTreeNode.GetNodeByVisibleIndex(listRoot, 1), Is.SameAs(child)); |
||||
} |
||||
} |
||||
@ -0,0 +1,444 @@
@@ -0,0 +1,444 @@
|
||||
// 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
|
||||
} |
||||
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
// 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.Linq; |
||||
|
||||
using ICSharpCode.ILSpyX.TreeView; |
||||
|
||||
using NUnit.Framework; |
||||
using NUnit.Framework.Interfaces; |
||||
|
||||
[assembly: TreeThreadAffinityGuard] |
||||
|
||||
// Deliberately outside any namespace: this is applied to the assembly, and the attribute has to be
|
||||
// nameable from the assembly-level attribute list.
|
||||
|
||||
/// <summary>
|
||||
/// Fails any test that mutated a displayed tree from a thread other than the tree's owner.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The affinity check records into <see cref="TreeThreadAffinity.Violations"/> instead of relying
|
||||
/// on a throw, because tree mutation happens inside callers that catch Exception - the background
|
||||
/// decompile turns any exception a node raises into text in the output pane. A throw there is
|
||||
/// swallowed and the run still passes, so something has to read the collector, and it has to be
|
||||
/// per test: an assembly-level teardown failure is reported but does not fail the run or change
|
||||
/// the exit code. Debug builds only - the check compiles away in release, where the collector
|
||||
/// stays empty and this is a no-op.
|
||||
///
|
||||
/// A fixture that provokes violations on purpose clears the collector in its own
|
||||
/// <c>[TearDown]</c>, which runs before this.
|
||||
/// </remarks>
|
||||
[AttributeUsage(AttributeTargets.Assembly)] |
||||
public sealed class TreeThreadAffinityGuardAttribute : Attribute, ITestAction |
||||
{ |
||||
public ActionTargets Targets => ActionTargets.Test; |
||||
|
||||
public void BeforeTest(ITest test) |
||||
{ |
||||
TreeThreadAffinity.Clear(); |
||||
} |
||||
|
||||
public void AfterTest(ITest test) |
||||
{ |
||||
var violations = TreeThreadAffinity.Violations; |
||||
if (violations.Count == 0) |
||||
return; |
||||
TreeThreadAffinity.Clear(); |
||||
Assert.Fail($"{violations.Count} tree thread-affinity violation(s) were recorded while this test ran:" |
||||
+ Environment.NewLine |
||||
+ string.Join(Environment.NewLine, violations.Select(v => v.ToString()))); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue