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