// 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; } /// /// 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. /// sealed class LazyTestNode : SharpTreeNode { readonly string text; public LazyTestNode(string text) { this.text = text; LazyLoading = true; } public Action? 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; } /// /// A stand-in for the UI thread: a thread with a work queue, whose 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. /// sealed class OwnerThread : IDisposable { readonly BlockingCollection 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; /// Number of calls that actually had to be marshalled. 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(); } /// /// Runs to completion on a dedicated thread and returns whatever it /// threw. Join() makes this deterministic: no sleeps, no polling. /// 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(); 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(); 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(); 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(); 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(); } [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(); 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 }