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 (_, _) => {