diff --git a/ICSharpCode.ILSpyX/TreeView/FlatListTreeNode.cs b/ICSharpCode.ILSpyX/TreeView/FlatListTreeNode.cs
index 1fa7473f2..0c0b1e0c7 100644
--- a/ICSharpCode.ILSpyX/TreeView/FlatListTreeNode.cs
+++ b/ICSharpCode.ILSpyX/TreeView/FlatListTreeNode.cs
@@ -39,7 +39,7 @@ namespace ICSharpCode.ILSpyX.TreeView
byte height = 1;
/// Length in the flat list, including children (children within the flat list). -1 = invalidated
- int totalListLength = -1;
+ internal int totalListLength = -1;
int Balance {
get { return Height(right) - Height(left); }
@@ -106,8 +106,16 @@ namespace ICSharpCode.ILSpyX.TreeView
root.GetTotalListLength(); // ensure all list lengths are calculated
Debug.Assert(index >= 0);
Debug.Assert(index < root.totalListLength);
+ int originalIndex = index;
SharpTreeNode node = root;
- while (true)
+ // Falling out of the descent means the augmented lengths describe more visible nodes
+ // than the tree actually holds, which is what a structural mutation racing this walk
+ // leaves behind (see TreeThreadAffinity). This is a mitigation, not a fix: it turns a
+ // NullReferenceException with no context into a report of the state that produced it,
+ // and it cannot make the read correct. Note it does not detect a stale index either -
+ // collapsing a node restructures the flat list without changing totalListLength, so the
+ // assert above stays satisfied while the structure moves underneath.
+ while (node != null)
{
if (node.left != null && index < node.left.totalListLength)
{
@@ -128,6 +136,9 @@ namespace ICSharpCode.ILSpyX.TreeView
node = node.right;
}
}
+ throw new InvalidOperationException(
+ $"The flat list tree ran out of nodes while looking up visible index {originalIndex}; "
+ + $"totalListLength is {root.totalListLength}. The tree was restructured while it was being read.");
}
internal static int GetVisibleIndexForNode(SharpTreeNode node)
diff --git a/ICSharpCode.ILSpyX/TreeView/SharpTreeNode.cs b/ICSharpCode.ILSpyX/TreeView/SharpTreeNode.cs
index 34c5931c9..8f22c7801 100644
--- a/ICSharpCode.ILSpyX/TreeView/SharpTreeNode.cs
+++ b/ICSharpCode.ILSpyX/TreeView/SharpTreeNode.cs
@@ -23,6 +23,7 @@ using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
+using System.Threading;
using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions;
@@ -365,10 +366,38 @@ namespace ICSharpCode.ILSpyX.TreeView
}
///
- /// Ensures the children were initialized (loads children if lazy loading is enabled)
+ /// Ensures the children were initialized (loads children if lazy loading is enabled).
///
+ ///
+ /// Realizing children mutates a tree that a UI may be indexing concurrently, so on a tree
+ /// that has an owner with a way onto it (see )
+ /// this marshals itself there rather than relying on every caller to remember: the app
+ /// realizes children from background decompile tasks in several places, and a caller that
+ /// forgets corrupts the flat list.
+ ///
+ /// A call that is already on the owning thread runs inline, which is what keeps a blocking
+ /// invoke from deadlocking on itself and keeps nesting cheap: once the outermost call has
+ /// marshalled, everything triggers below it is already on the
+ /// owner and hops no further.
+ ///
+ /// A tree with no owner is not marshalled: building a subtree on a worker and publishing it
+ /// on the UI thread is a legitimate pattern, and nothing is displaying that subtree yet.
+ ///
public void EnsureLazyChildren()
{
+ if (!LazyLoading)
+ return;
+ SharpTreeNode? ownerNode = OwnerNode;
+ if (ownerNode?.ownerInvoke is { } invoke && ownerNode.owner != Thread.CurrentThread)
+ invoke(LoadLazyChildren);
+ else
+ LoadLazyChildren();
+ }
+
+ void LoadLazyChildren()
+ {
+ // Re-checked after marshalling: the owning thread may have realized the children while
+ // the calling thread was queued behind it.
if (LazyLoading)
{
LazyLoading = false;
diff --git a/ICSharpCode.ILSpyX/TreeView/TreeThreadAffinity.cs b/ICSharpCode.ILSpyX/TreeView/TreeThreadAffinity.cs
index 4792fe0cd..2682a0bcf 100644
--- a/ICSharpCode.ILSpyX/TreeView/TreeThreadAffinity.cs
+++ b/ICSharpCode.ILSpyX/TreeView/TreeThreadAffinity.cs
@@ -28,24 +28,29 @@ using System.Threading;
namespace ICSharpCode.ILSpyX.TreeView
{
- // Thread-affinity checking for the tree model.
+ // 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 convention is that a
- // tree displayed by a UI is mutated only from that UI's thread, but ICSharpCode.ILSpyX is
+ // 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, and every later structural
- // mutation of that tree is verified against the owning thread.
+ // 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
{
-#if DEBUG
Thread? owner;
+ Action? ownerInvoke;
///
- /// The nearest explicit owner on the model-parent chain, or null when nothing on the chain
- /// has been claimed.
+ /// The nearest node on the model-parent chain that carries an explicit owner, or null when
+ /// nothing on the chain has been claimed.
///
///
/// Resolving the owner by walking up instead of stamping every node gives the propagation
@@ -59,48 +64,47 @@ namespace ICSharpCode.ILSpyX.TreeView
/// parent - and that attachment is itself a mutation of the owned tree, so it is checked.
///
///
- Thread? EffectiveOwner {
+ SharpTreeNode? OwnerNode {
get {
for (SharpTreeNode? node = this; node != null; node = node.modelParent)
{
if (node.owner != null)
- return node.owner;
+ return node;
}
return null;
}
}
-#endif
+
+ Thread? EffectiveOwner => OwnerNode?.owner;
///
/// Declares as the only thread allowed to structurally mutate this
- /// node and its subtree. Debug-only: the call is compiled away entirely in release builds.
+ /// node and its subtree.
///
+ ///
+ /// Runs an action on and blocks until it has completed - the host's
+ /// dispatcher invoke. Supplying it lets 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.
+ ///
///
/// 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 this
- /// check hunts for.
+ /// background thread taking ownership of a live tree away from the UI is the very race the
+ /// affinity check hunts for.
///
- [Conditional("DEBUG")]
- public void SetOwner(Thread owner)
+ public void SetOwner(Thread owner, Action? invoke = null)
{
-#if DEBUG
VerifyAccess(nameof(SetOwner));
this.owner = owner;
-#endif
+ this.ownerInvoke = invoke;
}
///
/// Declares the calling thread as the only thread allowed to structurally mutate this node
- /// and its subtree. Debug-only.
+ /// and its subtree, without a way to marshal onto it.
///
- [Conditional("DEBUG")]
- public void SetOwner()
- {
-#if DEBUG
- SetOwner(Thread.CurrentThread);
-#endif
- }
+ public void SetOwner() => SetOwner(Thread.CurrentThread);
///
/// Reports a violation if the calling thread is not the effective owner of this node.
@@ -137,6 +141,7 @@ namespace ICSharpCode.ILSpyX.TreeView
{
TreeThreadAffinity.Report(node, "attach of a subtree owned by another thread", node.owner);
node.owner = null;
+ node.ownerInvoke = null;
}
}
#endif
@@ -164,9 +169,11 @@ namespace ICSharpCode.ILSpyX.TreeView
static readonly object logLock = new();
///
- /// When true, a violation throws instead of being recorded. Tests use this to observe a
- /// violation deterministically; an exploratory session over a large corpus leaves it off so
- /// that one bad call site does not end the run and hide all the others.
+ /// When true, a violation throws after being recorded, so a debugger stops at the offending
+ /// frame. It is not what makes a violation observable - 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.
///
public static bool FailFast { get; set; } = Environment.GetEnvironmentVariable(FailFastVariable) == "1";
@@ -196,8 +203,6 @@ namespace ICSharpCode.ILSpyX.TreeView
// 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);
- if (FailFast)
- throw new InvalidOperationException(violation.ToString());
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
@@ -205,6 +210,13 @@ namespace ICSharpCode.ILSpyX.TreeView
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)
diff --git a/ILSpy.Tests/Controls/FlatListTreeNodeTests.cs b/ILSpy.Tests/Controls/FlatListTreeNodeTests.cs
new file mode 100644
index 000000000..d3f4492d0
--- /dev/null
+++ b/ILSpy.Tests/Controls/FlatListTreeNodeTests.cs
@@ -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(
+ () => 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));
+ }
+}
diff --git a/ILSpy.Tests/Controls/TreeThreadAffinityTests.cs b/ILSpy.Tests/Controls/TreeThreadAffinityTests.cs
index 832dc3e2c..9f519c248 100644
--- a/ILSpy.Tests/Controls/TreeThreadAffinityTests.cs
+++ b/ILSpy.Tests/Controls/TreeThreadAffinityTests.cs
@@ -17,6 +17,8 @@
// DEALINGS IN THE SOFTWARE.
using System;
+using System.Collections.Concurrent;
+using System.Runtime.ExceptionServices;
using System.Threading;
using AwesomeAssertions;
@@ -38,6 +40,96 @@ public class TreeThreadAffinityTests
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;
@@ -83,8 +175,115 @@ public class TreeThreadAffinityTests
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()
{
diff --git a/ILSpy.Tests/TreeThreadAffinityGuard.cs b/ILSpy.Tests/TreeThreadAffinityGuard.cs
new file mode 100644
index 000000000..6e5f4084c
--- /dev/null
+++ b/ILSpy.Tests/TreeThreadAffinityGuard.cs
@@ -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.
+
+///
+/// Fails any test that mutated a displayed tree from a thread other than the tree's owner.
+///
+///
+/// The affinity check records into 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
+/// [TearDown], which runs before this.
+///
+[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())));
+ }
+}
diff --git a/ILSpy/Controls/TreeView/SharpTreeView.cs b/ILSpy/Controls/TreeView/SharpTreeView.cs
index a8e71cb4a..2e2b0bcbe 100644
--- a/ILSpy/Controls/TreeView/SharpTreeView.cs
+++ b/ILSpy/Controls/TreeView/SharpTreeView.cs
@@ -20,6 +20,7 @@ using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
+using System.Threading;
using Avalonia;
using Avalonia.Controls;
@@ -133,7 +134,11 @@ namespace ICSharpCode.ILSpy.Controls.TreeView
// ownership of it. Avalonia raises property changes on the UI thread, so the calling
// thread is the right owner. Every SharpTreeView in the app routes through Reload,
// so this single call claims every displayed tree.
- Root.SetOwner();
+ //
+ // The dispatcher invoke goes with it: EnsureLazyChildren uses it to get back onto
+ // this thread when a background decompile realizes a node's children, which the
+ // tree model cannot do for itself - it must not name a UI framework.
+ Root.SetOwner(Thread.CurrentThread, action => Dispatcher.UIThread.Invoke(action));
if (!(ShowRoot && ShowRootExpander))
Root.IsExpanded = true;
flattener = new TreeFlattener(Root, ShowRoot);
diff --git a/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs b/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs
index a72d81bce..bad5ba817 100644
--- a/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs
+++ b/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs
@@ -19,8 +19,6 @@
using System.Linq;
using System.Threading.Tasks;
-using Avalonia.Threading;
-
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.ILSpy.Properties;
@@ -69,8 +67,7 @@ namespace ICSharpCode.ILSpy.TreeNodes
output.WriteLine($"Effective TargetFramework-Id: {effectiveTargetFramework}");
output.WriteLine($"Detected RuntimePack: {runtimePack}");
- // Children realise lazily on the UI thread; we may run from a background decompile.
- Dispatcher.UIThread.Invoke(EnsureLazyChildren);
+ EnsureLazyChildren();
output.WriteLine();
output.WriteLine("Referenced assemblies (in metadata order):");
foreach (var node in Children.OfType())
diff --git a/ILSpy/TreeNodes/ResourceListTreeNode.cs b/ILSpy/TreeNodes/ResourceListTreeNode.cs
index 518a6d2e6..dadc9b9fe 100644
--- a/ILSpy/TreeNodes/ResourceListTreeNode.cs
+++ b/ILSpy/TreeNodes/ResourceListTreeNode.cs
@@ -18,8 +18,6 @@
using System.Linq;
-using Avalonia.Threading;
-
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.ILSpy.Properties;
@@ -80,7 +78,7 @@ namespace ICSharpCode.ILSpy.TreeNodes
public override void Decompile(Language language, ITextOutput output, DecompilationOptions options)
{
- Dispatcher.UIThread.Invoke(EnsureLazyChildren);
+ EnsureLazyChildren();
foreach (var child in Children.OfType())
{
child.Decompile(language, output, options);