diff --git a/ILSpy.Tests/Diagnostics/InputDiagnosticsTests.cs b/ILSpy.Tests/Diagnostics/InputDiagnosticsTests.cs
new file mode 100644
index 000000000..4a8b98947
--- /dev/null
+++ b/ILSpy.Tests/Diagnostics/InputDiagnosticsTests.cs
@@ -0,0 +1,97 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// 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 Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+
+using AwesomeAssertions;
+
+using ILSpy.AppEnv;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Diagnostics;
+
+[TestFixture]
+public class InputDiagnosticsTests
+{
+ // Stands in for Tmds.DBus.Protocol.DBusException (a transitive dependency we don't reference):
+ // InputDiagnostics matches it by type Name and reflects ErrorName/ErrorMessage off it.
+ sealed class DBusException : Exception
+ {
+ public DBusException(string errorName, string errorMessage) : base(errorName + ": " + errorMessage)
+ {
+ ErrorName = errorName;
+ ErrorMessage = errorMessage;
+ }
+
+ public string ErrorName { get; }
+ public string ErrorMessage { get; }
+ }
+
+ [Test]
+ public void DescribeException_Unwraps_Aggregate_And_Reflects_DBus_ErrorName()
+ {
+ var dbus = new DBusException("org.freedesktop.DBus.Error.ServiceUnknown", "The name is not activatable");
+ var aggregate = new AggregateException("A Task's exception(s) were not observed",
+ new InvalidOperationException("outer", dbus));
+
+ InputDiagnostics.ContainsDBusException(aggregate).Should().BeTrue();
+
+ var report = InputDiagnostics.DescribeException(aggregate);
+ report.Should().Contain("InvalidOperationException");
+ report.Should().Contain("DBusException");
+ report.Should().Contain("ErrorName = org.freedesktop.DBus.Error.ServiceUnknown",
+ "the reflected DBus ErrorName is the detail a bare ToString() buries");
+ report.Should().Contain("ErrorMessage = The name is not activatable");
+ }
+
+ [Test]
+ public void ContainsDBusException_Is_False_For_Ordinary_Exceptions()
+ {
+ InputDiagnostics.ContainsDBusException(new InvalidOperationException("nope")).Should().BeFalse();
+ InputDiagnostics.ContainsDBusException(null).Should().BeFalse();
+ }
+
+ [AvaloniaTest]
+ public void Attach_Records_Keyboard_Interactions_When_DBusDebug_Enabled()
+ {
+ AppLog.Enable(AppLog.Category.DBusDebug);
+ InputDiagnostics.Record("--- test marker ---");
+
+ var window = new Window { Width = 200, Height = 200 };
+ var box = new TextBox();
+ window.Content = box;
+ InputDiagnostics.Attach(window);
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ box.Focus();
+ Dispatcher.UIThread.RunJobs();
+
+ window.KeyPress(Key.A, RawInputModifiers.None, PhysicalKey.A, keySymbol: "a");
+ Dispatcher.UIThread.RunJobs();
+
+ InputDiagnostics.DumpRecent().Should().Contain("KeyDown A",
+ "every keyboard interaction must land on the DBUSDEBUG input trail");
+ }
+}
diff --git a/ILSpy/AppEnv/AppLog.cs b/ILSpy/AppEnv/AppLog.cs
index 1dc39827a..b8b638721 100644
--- a/ILSpy/AppEnv/AppLog.cs
+++ b/ILSpy/AppEnv/AppLog.cs
@@ -59,6 +59,14 @@ namespace ILSpy.AppEnv
/// Dock chrome activity — view-recycling cache, layout save/load, drag/drop transitions.
public const string Docking = "Docking";
+
+ ///
+ /// Mouse/keyboard interaction trail plus unwrapped DBus exception reports, for
+ /// correlating an unobserved Tmds.DBus error (which surfaces asynchronously on
+ /// the finalizer thread) with the interaction that triggered the DBus call. Off by
+ /// default; opt in with ILSPY_LOG=DBUSDEBUG (matched case-insensitively).
+ ///
+ public const string DBusDebug = "DBusDebug";
}
static readonly Stopwatch sw = Stopwatch.StartNew();
diff --git a/ILSpy/AppEnv/GlobalExceptionHandler.cs b/ILSpy/AppEnv/GlobalExceptionHandler.cs
index b394b129c..37539d240 100644
--- a/ILSpy/AppEnv/GlobalExceptionHandler.cs
+++ b/ILSpy/AppEnv/GlobalExceptionHandler.cs
@@ -66,9 +66,24 @@ namespace ILSpy.AppEnv
static void OnUnobservedTask(object? sender, UnobservedTaskExceptionEventArgs e)
{
e.SetObserved();
+ // The Linux DBus failures arrive here: a fire-and-forget Tmds.DBus call faults and the
+ // AggregateException is rethrown by the finalizer. Log the unwrapped chain plus the
+ // recent input trail so the triggering gesture can be read off (ILSPY_LOG=DBUSDEBUG).
+ LogDBusDiagnostics(e.Exception);
Report(e.Exception);
}
+ static void LogDBusDiagnostics(Exception exception)
+ {
+ if (!AppLog.IsEnabled(AppLog.Category.DBusDebug))
+ return;
+ AppLog.Write(AppLog.Category.DBusDebug,
+ "UNOBSERVED TASK EXCEPTION (finalizer thread)" + Environment.NewLine
+ + InputDiagnostics.DescribeException(exception) + Environment.NewLine
+ + "Recent input trail (newest last):" + Environment.NewLine
+ + InputDiagnostics.DumpRecent());
+ }
+
static void OnDispatcherUnhandled(object sender, DispatcherUnhandledExceptionEventArgs e)
{
Report(e.Exception);
@@ -153,13 +168,21 @@ namespace ILSpy.AppEnv
static (Control root, TextBox details) BuildContent(Exception exception, Window window, string clipboardText)
{
+ // For DBus failures the bare ToString() hides the ServiceUnknown/ErrorName detail inside
+ // nested aggregates; append the fully unwrapped chain so the dialog carries it too.
+ var detailText = exception.ToString();
+ if (InputDiagnostics.ContainsDBusException(exception))
+ detailText += Environment.NewLine + Environment.NewLine
+ + "--- unwrapped DBus chain ---" + Environment.NewLine
+ + InputDiagnostics.DescribeException(exception);
+
var details = new TextBox {
IsReadOnly = true,
AcceptsReturn = true,
TextWrapping = TextWrapping.NoWrap,
FontFamily = new FontFamily("Consolas, Menlo, Monospace"),
FontSize = 12,
- Text = exception.ToString(),
+ Text = detailText,
};
var summary = new TextBlock {
@@ -197,7 +220,14 @@ namespace ILSpy.AppEnv
}
static string FormatForClipboard(Exception exception)
- => exception.GetType().FullName + ": " + exception.Message + Environment.NewLine + exception;
+ {
+ var text = exception.GetType().FullName + ": " + exception.Message + Environment.NewLine + exception;
+ if (InputDiagnostics.ContainsDBusException(exception))
+ text += Environment.NewLine + Environment.NewLine
+ + "--- unwrapped DBus chain ---" + Environment.NewLine
+ + InputDiagnostics.DescribeException(exception);
+ return text;
+ }
static void CopyToClipboard(Window window, string text)
{
diff --git a/ILSpy/AppEnv/InputDiagnostics.cs b/ILSpy/AppEnv/InputDiagnostics.cs
new file mode 100644
index 000000000..1605b1049
--- /dev/null
+++ b/ILSpy/AppEnv/InputDiagnostics.cs
@@ -0,0 +1,245 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// 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.Generic;
+using System.Reflection;
+using System.Text;
+
+using Avalonia;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace ILSpy.AppEnv
+{
+ ///
+ /// Diagnostics for chasing the asynchronous DBus failure on Linux: an unobserved
+ /// Tmds.DBus.Protocol.DBusException (e.g. org.freedesktop.DBus.Error.ServiceUnknown)
+ /// surfaces on the finalizer thread, temporally disconnected from the mouse/keyboard interaction
+ /// that made Avalonia issue the DBus call (AT-SPI accessibility, portals, clipboard, IME, …).
+ ///
+ /// records every input event into a rolling ring buffer (and the
+ /// log), so when the exception finally fires we can dump
+ /// the recent interaction trail next to the unwrapped exception and read off the trigger.
+ ///
+ /// Everything here is gated on (opt in with
+ /// ILSPY_LOG=DBUSDEBUG); with the category off, installs no handlers
+ /// and the app pays nothing.
+ ///
+ public static class InputDiagnostics
+ {
+ const int RingCapacity = 256;
+ const long MoveThrottleMs = 50;
+
+ static readonly object ringLock = new();
+ static readonly Queue ring = new(RingCapacity + 1);
+ static long lastMoveTick;
+
+ ///
+ /// Installs tunnel/bubble handlers for every mouse and keyboard routed event on
+ /// (typically the main window). No-op unless the
+ /// category is enabled at the time of the call, so it
+ /// is a launch-time flag: set ILSPY_LOG=DBUSDEBUG before starting the process.
+ ///
+ public static void Attach(InputElement root)
+ {
+ ArgumentNullException.ThrowIfNull(root);
+ if (!AppLog.IsEnabled(AppLog.Category.DBusDebug))
+ return;
+
+ // Tunnel + handledEventsToo for the gesture events so we log them on the way DOWN,
+ // before any control handles them (and possibly triggers the DBus call) -- the log line
+ // then reliably precedes the DBus error in time. Focus/enter/exit have no tunnel route,
+ // so register those on Bubble.
+ root.AddHandler(InputElement.KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true);
+ root.AddHandler(InputElement.KeyUpEvent, OnKeyUp, RoutingStrategies.Tunnel, handledEventsToo: true);
+ root.AddHandler(InputElement.TextInputEvent, OnTextInput, RoutingStrategies.Tunnel, handledEventsToo: true);
+ root.AddHandler(InputElement.PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true);
+ root.AddHandler(InputElement.PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel, handledEventsToo: true);
+ root.AddHandler(InputElement.PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true);
+ root.AddHandler(InputElement.PointerWheelChangedEvent, OnPointerWheel, RoutingStrategies.Tunnel, handledEventsToo: true);
+ root.AddHandler(InputElement.PointerEnteredEvent, OnPointerEntered, RoutingStrategies.Bubble, handledEventsToo: true);
+ root.AddHandler(InputElement.PointerExitedEvent, OnPointerExited, RoutingStrategies.Bubble, handledEventsToo: true);
+ root.AddHandler(InputElement.GotFocusEvent, OnGotFocus, RoutingStrategies.Bubble, handledEventsToo: true);
+ root.AddHandler(InputElement.LostFocusEvent, OnLostFocus, RoutingStrategies.Bubble, handledEventsToo: true);
+
+ Record($"input diagnostics attached to {Src(root)}");
+ }
+
+ ///
+ /// Appends one timestamped line to the ring buffer and writes it under
+ /// . Public so non-input subsystems can drop markers
+ /// onto the same trail when chasing the DBus issue.
+ ///
+ public static void Record(string message)
+ {
+ var line = $"[{DateTime.Now:HH:mm:ss.fff}] {message}";
+ lock (ringLock)
+ {
+ ring.Enqueue(line);
+ while (ring.Count > RingCapacity)
+ ring.Dequeue();
+ }
+ AppLog.Write(AppLog.Category.DBusDebug, message);
+ }
+
+ /// Newest-last snapshot of the recent interaction trail, one event per line.
+ public static string DumpRecent()
+ {
+ lock (ringLock)
+ {
+ return ring.Count == 0 ? "(no input recorded)" : string.Join(Environment.NewLine, ring);
+ }
+ }
+
+ #region Input handlers
+
+ static void OnKeyDown(object? sender, KeyEventArgs e)
+ => Record($"KeyDown {e.Key} mods={e.KeyModifiers} src={Src(e.Source)} handled={e.Handled}");
+
+ static void OnKeyUp(object? sender, KeyEventArgs e)
+ => Record($"KeyUp {e.Key} mods={e.KeyModifiers} src={Src(e.Source)}");
+
+ static void OnTextInput(object? sender, TextInputEventArgs e)
+ => Record($"TextInput '{e.Text}' src={Src(e.Source)}");
+
+ static void OnPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ var point = e.GetCurrentPoint(sender as Visual);
+ Record($"PtrPressed {point.Properties.PointerUpdateKind} @{Pos(point.Position)} clicks={e.ClickCount} src={Src(e.Source)}");
+ }
+
+ static void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ var point = e.GetCurrentPoint(sender as Visual);
+ Record($"PtrReleased {point.Properties.PointerUpdateKind} @{Pos(point.Position)} src={Src(e.Source)}");
+ }
+
+ static void OnPointerMoved(object? sender, PointerEventArgs e)
+ {
+ // Moves can fire hundreds of times a second; throttle so the trail stays readable while
+ // still bracketing the moment a DBus call fired.
+ var now = Environment.TickCount64;
+ if (now - lastMoveTick < MoveThrottleMs)
+ return;
+ lastMoveTick = now;
+ Record($"PtrMoved @{Pos(e.GetPosition(sender as Visual))} src={Src(e.Source)}");
+ }
+
+ static void OnPointerWheel(object? sender, PointerWheelEventArgs e)
+ => Record($"PtrWheel delta={e.Delta} src={Src(e.Source)}");
+
+ static void OnPointerEntered(object? sender, PointerEventArgs e)
+ => Record($"PtrEntered src={Src(e.Source)}");
+
+ static void OnPointerExited(object? sender, PointerEventArgs e)
+ => Record($"PtrExited src={Src(e.Source)}");
+
+ static void OnGotFocus(object? sender, RoutedEventArgs e)
+ => Record($"GotFocus src={Src(e.Source)}");
+
+ static void OnLostFocus(object? sender, RoutedEventArgs e)
+ => Record($"LostFocus src={Src(e.Source)}");
+
+ #endregion
+
+ static string Src(object? source)
+ {
+ if (source is StyledElement element)
+ return string.IsNullOrEmpty(element.Name) ? element.GetType().Name : $"{element.GetType().Name}#{element.Name}";
+ return source?.GetType().Name ?? "null";
+ }
+
+ static string Pos(Point p) => $"{p.X:0},{p.Y:0}";
+
+ #region DBus exception unwrapping
+
+ ///
+ /// True if (flattened, including inner exceptions) carries a
+ /// Tmds.DBus.Protocol.DBusException.
+ ///
+ public static bool ContainsDBusException(Exception? exception)
+ {
+ foreach (var root in Roots(exception))
+ for (var ex = root; ex != null; ex = ex.InnerException)
+ if (IsDBusException(ex))
+ return true;
+ return false;
+ }
+
+ ///
+ /// Fully unwraps -- flattens any ,
+ /// walks every inner-exception chain, and for each DBusException reflects the
+ /// ErrorName / ErrorMessage the bare ToString() hides -- into a readable
+ /// multi-line report. We reflect because Tmds.DBus is a transitive dependency we don't
+ /// reference directly.
+ ///
+ public static string DescribeException(Exception? exception)
+ {
+ if (exception == null)
+ return "(null exception)";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"{exception.GetType().FullName}: {exception.Message}");
+ var roots = Roots(exception);
+ for (int i = 0; i < roots.Count; i++)
+ {
+ sb.AppendLine($"--- branch {i + 1}/{roots.Count} ---");
+ int depth = 0;
+ for (var ex = roots[i]; ex != null; ex = ex.InnerException, depth++)
+ {
+ var indent = new string(' ', 2 + depth * 2);
+ sb.AppendLine($"{indent}{ex.GetType().FullName}: {ex.Message}");
+ AppendDBusDetails(sb, ex, indent + " ");
+ }
+ if (roots[i].StackTrace is { Length: > 0 } stack)
+ {
+ sb.AppendLine(" stack:");
+ sb.AppendLine(stack);
+ }
+ }
+ return sb.ToString();
+ }
+
+ static void AppendDBusDetails(StringBuilder sb, Exception ex, string indent)
+ {
+ if (!IsDBusException(ex))
+ return;
+ var type = ex.GetType();
+ foreach (var property in new[] { "ErrorName", "ErrorMessage" })
+ {
+ var value = type.GetProperty(property, BindingFlags.Public | BindingFlags.Instance)?.GetValue(ex);
+ if (value != null)
+ sb.AppendLine($"{indent}{property} = {value}");
+ }
+ }
+
+ static bool IsDBusException(Exception ex)
+ => ex.GetType().FullName == "Tmds.DBus.Protocol.DBusException"
+ || ex.GetType().Name == "DBusException";
+
+ static IReadOnlyList Roots(Exception? exception)
+ {
+ if (exception is AggregateException aggregate)
+ return aggregate.Flatten().InnerExceptions;
+ return exception == null ? Array.Empty() : new[] { exception };
+ }
+
+ #endregion
+ }
+}
diff --git a/ILSpy/Views/MainWindow.axaml.cs b/ILSpy/Views/MainWindow.axaml.cs
index 5930f5102..00d352aa8 100644
--- a/ILSpy/Views/MainWindow.axaml.cs
+++ b/ILSpy/Views/MainWindow.axaml.cs
@@ -55,6 +55,10 @@ namespace ILSpy.Views
AppLog.Mark("MainWindow XAML inflation about to start (DataContext set)");
InitializeComponent();
AppLog.Mark("MainWindow XAML inflation done");
+ // Records every mouse/keyboard interaction under ILSPY_LOG=DBUSDEBUG so an unobserved
+ // DBus error (which surfaces later on the finalizer thread) can be traced back to the
+ // gesture that triggered the DBus call. No-op unless that category is enabled.
+ InputDiagnostics.Attach(this);
ILSpy.MainMenu.Attach(this);
ApplySessionSettings(settingsService.SessionSettings);
Opened += async (_, _) => {