Browse Source

Fix #3290: marshal EnsureLazyChildren onto the tree's owning thread

The crash is a NullReferenceException in GetNodeByVisibleIndex, reached when a
background decompile realizes a node's children while the UI thread is indexing
the flattener. Eight ILSpyTreeNode.Decompile overrides call EnsureLazyChildren
from that task; two wrapped it in Dispatcher.UIThread.Invoke, six did not, and
one of the two lost its wrapper in the Avalonia port with no test noticing for a
release cycle. A rule every call site has to remember is a rule that gets broken
again, so EnsureLazyChildren marshals itself instead: SetOwner already named the
owning thread, and now also carries the host's way onto it. A call already on the
owner runs inline, so a blocking invoke cannot deadlock on itself and a nested
load costs no further hop; an unowned tree is left unmarshalled, which keeps
building a subtree on a worker and publishing it on the UI thread legal.

The affinity check stays as the regression detector, but its fail-fast throw was
worthless on its own: tree mutation happens inside callers that catch Exception,
so the throw ended up rendered into the decompiled output and the run passed. The
violation is now recorded before the throw, and an assembly-level NUnit test
action fails the test that produced one - an assembly-level teardown failure is
reported but leaves the exit code at zero.

Assisted-by: Claude:claude-opus-5:Claude Code
pull/4099/head
Siegfried Pammer 2 weeks ago
parent
commit
228041085b
  1. 15
      ICSharpCode.ILSpyX/TreeView/FlatListTreeNode.cs
  2. 31
      ICSharpCode.ILSpyX/TreeView/SharpTreeNode.cs
  3. 74
      ICSharpCode.ILSpyX/TreeView/TreeThreadAffinity.cs
  4. 72
      ILSpy.Tests/Controls/FlatListTreeNodeTests.cs
  5. 199
      ILSpy.Tests/Controls/TreeThreadAffinityTests.cs
  6. 67
      ILSpy.Tests/TreeThreadAffinityGuard.cs
  7. 7
      ILSpy/Controls/TreeView/SharpTreeView.cs
  8. 5
      ILSpy/TreeNodes/ReferenceFolderTreeNode.cs
  9. 4
      ILSpy/TreeNodes/ResourceListTreeNode.cs

15
ICSharpCode.ILSpyX/TreeView/FlatListTreeNode.cs

@ -39,7 +39,7 @@ namespace ICSharpCode.ILSpyX.TreeView @@ -39,7 +39,7 @@ namespace ICSharpCode.ILSpyX.TreeView
byte height = 1;
/// <summary>Length in the flat list, including children (children within the flat list). -1 = invalidated</summary>
int totalListLength = -1;
internal int totalListLength = -1;
int Balance {
get { return Height(right) - Height(left); }
@ -106,8 +106,16 @@ namespace ICSharpCode.ILSpyX.TreeView @@ -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 @@ -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)

31
ICSharpCode.ILSpyX/TreeView/SharpTreeNode.cs

@ -23,6 +23,7 @@ using System.ComponentModel; @@ -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 @@ -365,10 +366,38 @@ namespace ICSharpCode.ILSpyX.TreeView
}
/// <summary>
/// Ensures the children were initialized (loads children if lazy loading is enabled)
/// Ensures the children were initialized (loads children if lazy loading is enabled).
/// </summary>
/// <remarks>
/// 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 <see cref="SetOwner(Thread, Action{Action})"/>)
/// 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 <see cref="LoadChildren"/> 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.
/// </remarks>
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;

74
ICSharpCode.ILSpyX/TreeView/TreeThreadAffinity.cs

@ -28,24 +28,29 @@ using System.Threading; @@ -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<Action>? ownerInvoke;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Resolving the owner by walking up instead of stamping every node gives the propagation
@ -59,48 +64,47 @@ namespace ICSharpCode.ILSpyX.TreeView @@ -59,48 +64,47 @@ namespace ICSharpCode.ILSpyX.TreeView
/// parent - and that attachment is itself a mutation of the owned tree, so it is checked.</item>
/// </list>
/// </remarks>
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;
/// <summary>
/// Declares <paramref name="owner"/> 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.
/// </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 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.
/// </remarks>
[Conditional("DEBUG")]
public void SetOwner(Thread owner)
public void SetOwner(Thread owner, Action<Action>? invoke = null)
{
#if DEBUG
VerifyAccess(nameof(SetOwner));
this.owner = owner;
#endif
this.ownerInvoke = invoke;
}
/// <summary>
/// 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.
/// </summary>
[Conditional("DEBUG")]
public void SetOwner()
{
#if DEBUG
SetOwner(Thread.CurrentThread);
#endif
}
public void SetOwner() => SetOwner(Thread.CurrentThread);
/// <summary>
/// Reports a violation if the calling thread is not the effective owner of this node.
@ -137,6 +141,7 @@ namespace ICSharpCode.ILSpyX.TreeView @@ -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 @@ -164,9 +169,11 @@ namespace ICSharpCode.ILSpyX.TreeView
static readonly object logLock = new();
/// <summary>
/// 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 - <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";
@ -196,8 +203,6 @@ namespace ICSharpCode.ILSpyX.TreeView @@ -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 @@ -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)

72
ILSpy.Tests/Controls/FlatListTreeNodeTests.cs

@ -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));
}
}

199
ILSpy.Tests/Controls/TreeThreadAffinityTests.cs

@ -17,6 +17,8 @@ @@ -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 @@ -38,6 +40,96 @@ public class TreeThreadAffinityTests
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;
@ -83,8 +175,115 @@ public class TreeThreadAffinityTests @@ -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()
{

67
ILSpy.Tests/TreeThreadAffinityGuard.cs

@ -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())));
}
}

7
ILSpy/Controls/TreeView/SharpTreeView.cs

@ -20,6 +20,7 @@ using System; @@ -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 @@ -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);

5
ILSpy/TreeNodes/ReferenceFolderTreeNode.cs

@ -19,8 +19,6 @@ @@ -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 @@ -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<ILSpyTreeNode>())

4
ILSpy/TreeNodes/ResourceListTreeNode.cs

@ -18,8 +18,6 @@ @@ -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 @@ -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<ILSpyTreeNode>())
{
child.Decompile(language, output, options);

Loading…
Cancel
Save