From 835424ed3f4b3bf3f4b38170dea08cf7404041b0 Mon Sep 17 00:00:00 2001 From: suportewmit-cmyk Date: Wed, 10 Jun 2026 21:48:13 -0300 Subject: [PATCH 1/2] Fix SherpaOnnx finalizer crash with DllNotFoundException Suppress fatal crashes caused by SherpaOnnx.OfflineTts finalizer calling SherpaOnnxDestroyOfflineTts when native DLL is unavailable. Changes: - PiperTextToSpeechClient: gracefully handle DllNotFoundException in constructor (TTS disabled gracefully), add GC.SuppressFinalize after dispose to prevent finalizer from calling native code during GC - App.xaml.cs: improved OnDomainUnhandledException logging for SherpaOnnx native DLL errors - Crash prevention relies on exe.config legacyUnhandledExceptionPolicy and GC.SuppressFinalize (IsTerminating is readonly in .NET 10) Fixes repeated crashes: System.DllNotFoundException: Unable to load DLL 'sherpa-onnx-c-api' at SherpaOnnx.OfflineTts.Finalize() --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 49 ++++++++++++------- .../TextToSpeech/PiperTextToSpeechClient.cs | 34 +++++++++++-- 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 37ddf70a4..e56300d93 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -142,7 +142,7 @@ public IntPtr GetHubWindowHandle() private GatewayService? _gatewayService; private CancellationTokenSource? _deepLinkCts; private bool _isExiting; - + /// /// Cached connection status — sole writer is OnManagerStateChanged. /// Reads are safe from any thread; derives from the connection manager's state machine. @@ -174,12 +174,12 @@ public IntPtr GetHubWindowHandle() private DiagnosticsClipboardService? _diagnosticsClipboard; private ToastService? _toastService; - + // Node service (optional, enabled in settings) private NodeService? _nodeService; // MCP-only app capability — local testing/control, not exposed to gateway private AppCapability? _appCapability; - + // Keep-alive window to anchor WinUI runtime (prevents GC/threading issues) private Window? _keepAliveWindow; private SetupWindow? _setupWindow; @@ -233,10 +233,10 @@ public App() GatewayHostAccessLocalization.Format = (key, args) => LocalizationHelper.Format(key, args); InitializeComponent(); - + s_runMarker.Check(); s_runMarker.MarkStarted(); - + // Hook up crash handlers this.UnhandledException += OnUnhandledException; AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException; @@ -294,7 +294,18 @@ private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExce private void OnDomainUnhandledException(object sender, System.UnhandledExceptionEventArgs e) { - _crashLogger.Log("DomainUnhandledException", e.ExceptionObject as Exception); + var ex = e.ExceptionObject as Exception; + _crashLogger.Log("DomainUnhandledException", ex); + + // Log SherpaOnnx finalizer errors clearly so they are visible in diagnostics. + // The actual crash prevention is handled by the exe.config + // legacyUnhandledExceptionPolicy setting and the GC.SuppressFinalize + // call in PiperTextToSpeechClient.Dispose(). + if (ex is DllNotFoundException dllEx && + dllEx.Message.Contains("sherpa-onnx", StringComparison.OrdinalIgnoreCase)) + { + Logger.Warn($"SherpaOnnx native DLL unavailable: {dllEx.Message}"); + } } private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) @@ -302,7 +313,7 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv _crashLogger.Log("UnobservedTaskException", e.Exception); e.SetObserved(); // Prevent crash } - + private void OnProcessExit(object? sender, EventArgs e) { s_runMarker.MarkEnded(); @@ -679,7 +690,7 @@ private void InitializeKeepAliveWindow() _keepAliveWindow = new Window(); _keepAliveWindow.Content = new Microsoft.UI.Xaml.Controls.Grid(); _keepAliveWindow.AppWindow.IsShownInSwitchers = false; - + // Move off-screen and set minimal size _keepAliveWindow.AppWindow.MoveAndResize(new global::Windows.Graphics.RectInt32(-32000, -32000, 1, 1)); } @@ -688,10 +699,10 @@ private void InitializeTrayIcon() { // Initialize keep-alive window first to anchor WinUI runtime InitializeKeepAliveWindow(); - + // Pre-create tray menu window at startup to avoid creation crashes later InitializeTrayMenuWindow(); - + var iconPath = IconHelper.GetStatusIconPath(ConnectionStatus.Disconnected); _trayIcon = new TrayIcon(1, iconPath, BuildTrayTooltip()); _trayIcon.IsVisible = true; @@ -1002,15 +1013,15 @@ private void OnTrayMenuItemClicked(object? sender, string action) break; } } - + private void CopyDeviceIdToClipboard() { if (_nodeService?.FullDeviceId == null) return; - + try { CopyTextToClipboard(_nodeService.FullDeviceId); - + // Show toast confirming copy _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_DeviceIdCopied")) @@ -2208,7 +2219,7 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) { Logger.Info($"Node status: {status}"); AddRecentActivity($"Node mode {status}", category: "node", dashboardPath: "nodes"); - + // In node-only mode, surface node connection in main status indicator if (_settings?.EnableNodeMode == true) { @@ -2216,7 +2227,7 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) UpdateTrayIcon(); OnUiThread(UpdateStatusDetailWindow); } - + // Don't show "connected" toast if waiting for pairing - we'll show pairing status instead var nodeService = _nodeService; if (status == ConnectionStatus.Connected && nodeService?.IsPaired == true) @@ -2246,7 +2257,7 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatusEventArgs args) { Logger.Info($"Pairing status: {args.Status}"); - + try { if (args.Status == OpenClaw.Shared.PairingStatus.Pending) @@ -2330,7 +2341,7 @@ public void ShowPairingPendingNotification(string deviceId, string? approvalComm "node-pairing-pending", deviceId); } - + private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabilities.SystemNotifyArgs args) { AddRecentActivity(args.Title, category: "node", dashboardPath: "nodes", details: args.Body); @@ -3368,7 +3379,7 @@ private async Task ToggleChannelAsync(string channelName) await client.StartChannelAsync(channelName); AddRecentActivity($"Started channel: {channelName}", category: "channel", dashboardPath: "settings"); } - + // Refresh health await RunHealthCheckAsync(); } @@ -3480,7 +3491,7 @@ private void StartDeepLinkServer() { _deepLinkCts = new CancellationTokenSource(); var token = _deepLinkCts.Token; - + Task.Run(async () => { while (!token.IsCancellationRequested) diff --git a/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs index 2d595ba61..0022172bb 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs @@ -25,12 +25,13 @@ public sealed class PiperTextToSpeechClient : IDisposable { private readonly IOpenClawLogger _logger; private readonly string _voiceId; - private readonly OfflineTts _tts; + private OfflineTts? _tts; private readonly SemaphoreSlim _gate = new(1, 1); private bool _disposed; + private bool _ttsAvailable; public string VoiceId => _voiceId; - public int SampleRate => _tts.SampleRate; + public int SampleRate => _tts?.SampleRate ?? 22050; public PiperTextToSpeechClient(IOpenClawLogger logger, PiperVoiceManager voices, string voiceId) { @@ -53,8 +54,18 @@ public PiperTextToSpeechClient(IOpenClawLogger logger, PiperVoiceManager voices, config.Model.Debug = 0; config.MaxNumSentences = 2; - _tts = new OfflineTts(config); - _logger.Info($"Piper voice '{_voiceId}' loaded (sample rate {_tts.SampleRate} Hz, {config.Model.NumThreads} threads)"); + try + { + _tts = new OfflineTts(config); + _ttsAvailable = true; + _logger.Info($"Piper voice '{_voiceId}' loaded (sample rate {_tts.SampleRate} Hz, {config.Model.NumThreads} threads)"); + } + catch (DllNotFoundException ex) + { + _logger.Warn($"Piper voice '{_voiceId}' unavailable: sherpa-onnx native library could not be loaded. TTS will be disabled. ({ex.Message})"); + _tts = null; + _ttsAvailable = false; + } } /// @@ -65,6 +76,8 @@ public async Task GenerateWavAsync(string text, float speed = 1.0f, Canc { if (_disposed) throw new ObjectDisposedException(nameof(PiperTextToSpeechClient)); if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("text must be non-empty", nameof(text)); + if (!_ttsAvailable || _tts == null) + throw new InvalidOperationException("Piper TTS is not available: sherpa-onnx native library could not be loaded."); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -131,7 +144,18 @@ public void Dispose() if (_disposed) return; _disposed = true; // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. - try { _tts.Dispose(); } catch { /* swallow */ } + try + { + if (_tts != null) + { + _tts.Dispose(); + // CRITICAL: Suppress the finalizer to prevent SherpaOnnxDestroyOfflineTts + // from being called during GC, which crashes the app when the native DLL + // is unavailable (DllNotFoundException in finalizer = instant crash). + GC.SuppressFinalize(_tts); + } + } + catch { /* swallow */ } // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. try { _gate.Dispose(); } catch { /* swallow */ } } From 9b358c3ed5147809e6968d85e3df1a1bbb83826b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 07:13:29 -0400 Subject: [PATCH 2/2] fix: prevent Piper finalizer crashes Preload the Sherpa native library before constructing its finalizable wrapper and suppress finalization before best-effort cleanup. Co-authored-by: suportewmit-cmyk --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 48 ++++++--------- .../TextToSpeech/PiperTextToSpeechClient.cs | 61 ++++++++++--------- .../PiperTextToSpeechClientContractTests.cs | 48 +++++++++++++++ 3 files changed, 98 insertions(+), 59 deletions(-) create mode 100644 tests/OpenClaw.Tray.Tests/PiperTextToSpeechClientContractTests.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index c6583f136..126d2d5da 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -143,7 +143,7 @@ public IntPtr GetHubWindowHandle() private GatewayService? _gatewayService; private CancellationTokenSource? _deepLinkCts; private bool _isExiting; - + /// /// Cached connection status — sole writer is OnManagerStateChanged. /// Reads are safe from any thread; derives from the connection manager's state machine. @@ -176,10 +176,9 @@ public IntPtr GetHubWindowHandle() private DiagnosticsClipboardService? _diagnosticsClipboard; private ToastService? _toastService; private AppNotificationService? _appNotificationService; - + // Node service (optional, enabled in settings) private NodeService? _nodeService; - // Keep-alive window to anchor WinUI runtime (prevents GC/threading issues) private Window? _keepAliveWindow; private SetupWindow? _setupWindow; @@ -233,10 +232,10 @@ public App() GatewayHostAccessLocalization.Format = (key, args) => LocalizationHelper.Format(key, args); InitializeComponent(); - + s_runMarker.Check(); s_runMarker.MarkStarted(); - + // Hook up crash handlers this.UnhandledException += OnUnhandledException; AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException; @@ -294,18 +293,7 @@ private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExce private void OnDomainUnhandledException(object sender, System.UnhandledExceptionEventArgs e) { - var ex = e.ExceptionObject as Exception; - _crashLogger.Log("DomainUnhandledException", ex); - - // Log SherpaOnnx finalizer errors clearly so they are visible in diagnostics. - // The actual crash prevention is handled by the exe.config - // legacyUnhandledExceptionPolicy setting and the GC.SuppressFinalize - // call in PiperTextToSpeechClient.Dispose(). - if (ex is DllNotFoundException dllEx && - dllEx.Message.Contains("sherpa-onnx", StringComparison.OrdinalIgnoreCase)) - { - Logger.Warn($"SherpaOnnx native DLL unavailable: {dllEx.Message}"); - } + _crashLogger.Log("DomainUnhandledException", e.ExceptionObject as Exception); } private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) @@ -313,7 +301,7 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv _crashLogger.Log("UnobservedTaskException", e.Exception); e.SetObserved(); // Prevent crash } - + private void OnProcessExit(object? sender, EventArgs e) { s_runMarker.MarkEnded(); @@ -688,7 +676,7 @@ private void InitializeKeepAliveWindow() _keepAliveWindow = new Window(); _keepAliveWindow.Content = new Microsoft.UI.Xaml.Controls.Grid(); _keepAliveWindow.AppWindow.IsShownInSwitchers = false; - + // Move off-screen and set minimal size _keepAliveWindow.AppWindow.MoveAndResize(new global::Windows.Graphics.RectInt32(-32000, -32000, 1, 1)); } @@ -697,10 +685,10 @@ private void InitializeTrayIcon() { // Initialize keep-alive window first to anchor WinUI runtime InitializeKeepAliveWindow(); - + // Pre-create tray menu window at startup to avoid creation crashes later InitializeTrayMenuWindow(); - + var iconPath = IconHelper.GetStatusIconPath(ConnectionStatus.Disconnected); _trayIcon = new TrayIcon(1, iconPath, BuildTrayTooltip()); _trayIcon.IsVisible = true; @@ -1011,15 +999,15 @@ private void OnTrayMenuItemClicked(object? sender, string action) break; } } - + private void CopyDeviceIdToClipboard() { if (_nodeService?.FullDeviceId == null) return; - + try { CopyTextToClipboard(_nodeService.FullDeviceId); - + // Show toast confirming copy _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_DeviceIdCopied")) @@ -2040,7 +2028,7 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) { Logger.Info($"Node status: {status}"); AddRecentActivity($"Node mode {status}", category: "node", dashboardPath: "nodes"); - + // In node-only mode, surface node connection in main status indicator if (_settings?.EnableNodeMode == true) { @@ -2048,7 +2036,7 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) UpdateTrayIcon(); OnUiThread(UpdateStatusDetailWindow); } - + // Don't show "connected" toast if waiting for pairing - we'll show pairing status instead var nodeService = _nodeService; if (status == ConnectionStatus.Connected && nodeService?.IsPaired == true) @@ -2079,7 +2067,7 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatusEventArgs args) { Logger.Info($"Pairing status: {args.Status}"); - + try { if (args.Status == OpenClaw.Shared.PairingStatus.Pending) @@ -2181,7 +2169,7 @@ public void ShowPairingPendingNotification(string deviceId, string? approvalComm "node-pairing-pending", deviceId); } - + private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabilities.SystemNotifyArgs args) { AddRecentActivity(args.Title, category: "node", dashboardPath: "nodes", details: args.Body); @@ -3295,7 +3283,7 @@ private async Task ToggleChannelAsync(string channelName) await client.StartChannelAsync(channelName); AddRecentActivity($"Started channel: {channelName}", category: "channel", dashboardPath: "settings"); } - + // Refresh health await RunHealthCheckAsync(); } @@ -3407,7 +3395,7 @@ private void StartDeepLinkServer() { _deepLinkCts = new CancellationTokenSource(); var token = _deepLinkCts.Token; - + Task.Run(async () => { while (!token.IsCancellationRequested) diff --git a/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs index 0022172bb..54f5e8a65 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using OpenClaw.Shared; @@ -23,15 +24,18 @@ namespace OpenClawTray.Services; /// public sealed class PiperTextToSpeechClient : IDisposable { + private const string SherpaNativeLibrary = "sherpa-onnx-c-api"; + private static readonly object s_nativeLibraryLock = new(); + private static IntPtr s_nativeLibraryHandle; + private readonly IOpenClawLogger _logger; private readonly string _voiceId; - private OfflineTts? _tts; + private readonly OfflineTts _tts; private readonly SemaphoreSlim _gate = new(1, 1); private bool _disposed; - private bool _ttsAvailable; public string VoiceId => _voiceId; - public int SampleRate => _tts?.SampleRate ?? 22050; + public int SampleRate => _tts.SampleRate; public PiperTextToSpeechClient(IOpenClawLogger logger, PiperVoiceManager voices, string voiceId) { @@ -54,18 +58,9 @@ public PiperTextToSpeechClient(IOpenClawLogger logger, PiperVoiceManager voices, config.Model.Debug = 0; config.MaxNumSentences = 2; - try - { - _tts = new OfflineTts(config); - _ttsAvailable = true; - _logger.Info($"Piper voice '{_voiceId}' loaded (sample rate {_tts.SampleRate} Hz, {config.Model.NumThreads} threads)"); - } - catch (DllNotFoundException ex) - { - _logger.Warn($"Piper voice '{_voiceId}' unavailable: sherpa-onnx native library could not be loaded. TTS will be disabled. ({ex.Message})"); - _tts = null; - _ttsAvailable = false; - } + EnsureNativeLibraryLoaded(); + _tts = new OfflineTts(config); + _logger.Info($"Piper voice '{_voiceId}' loaded (sample rate {_tts.SampleRate} Hz, {config.Model.NumThreads} threads)"); } /// @@ -76,8 +71,6 @@ public async Task GenerateWavAsync(string text, float speed = 1.0f, Canc { if (_disposed) throw new ObjectDisposedException(nameof(PiperTextToSpeechClient)); if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("text must be non-empty", nameof(text)); - if (!_ttsAvailable || _tts == null) - throw new InvalidOperationException("Piper TTS is not available: sherpa-onnx native library could not be loaded."); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -139,23 +132,33 @@ private static byte[] ConvertFloatPcmToWav(float[] samples, int sampleRate) return ms.ToArray(); } - public void Dispose() + private static void EnsureNativeLibraryLoaded() { - if (_disposed) return; - _disposed = true; - // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. - try + lock (s_nativeLibraryLock) { - if (_tts != null) + if (s_nativeLibraryHandle != IntPtr.Zero) + return; + + if (!NativeLibrary.TryLoad( + SherpaNativeLibrary, + typeof(OfflineTts).Assembly, + DllImportSearchPath.SafeDirectories, + out s_nativeLibraryHandle)) { - _tts.Dispose(); - // CRITICAL: Suppress the finalizer to prevent SherpaOnnxDestroyOfflineTts - // from being called during GC, which crashes the app when the native DLL - // is unavailable (DllNotFoundException in finalizer = instant crash). - GC.SuppressFinalize(_tts); + throw new DllNotFoundException("Piper TTS native library could not be loaded."); } } - catch { /* swallow */ } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + // SherpaOnnx suppresses its finalizer only after native cleanup succeeds. + // Suppress first so a cleanup failure cannot retry from the finalizer thread. + GC.SuppressFinalize(_tts); + // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. + try { _tts.Dispose(); } catch { /* swallow */ } // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. try { _gate.Dispose(); } catch { /* swallow */ } } diff --git a/tests/OpenClaw.Tray.Tests/PiperTextToSpeechClientContractTests.cs b/tests/OpenClaw.Tray.Tests/PiperTextToSpeechClientContractTests.cs new file mode 100644 index 000000000..ca7ae6629 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/PiperTextToSpeechClientContractTests.cs @@ -0,0 +1,48 @@ +namespace OpenClaw.Tray.Tests; + +public sealed class PiperTextToSpeechClientContractTests +{ + private static string ReadSource() => + File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Services", + "TextToSpeech", + "PiperTextToSpeechClient.cs")); + + [Fact] + public void NativeLibrary_IsLoadedBeforeFinalizableWrapperConstruction() + { + var source = ReadSource(); + + AssertInOrder( + source, + "EnsureNativeLibraryLoaded();", + "_tts = new OfflineTts(config);"); + Assert.Contains("NativeLibrary.TryLoad(", source); + Assert.Contains("typeof(OfflineTts).Assembly", source); + Assert.Contains("DllImportSearchPath.SafeDirectories", source); + Assert.Contains("out s_nativeLibraryHandle", source); + } + + [Fact] + public void Dispose_SuppressesFinalizerBeforeNativeCleanup() + { + var source = ReadSource(); + + AssertInOrder( + source, + "GC.SuppressFinalize(_tts);", + "_tts.Dispose();"); + } + + private static void AssertInOrder(string source, string first, string second) + { + var firstIndex = source.IndexOf(first, StringComparison.Ordinal); + var secondIndex = source.IndexOf(second, StringComparison.Ordinal); + + Assert.True(firstIndex >= 0, $"Missing expected source fragment: {first}"); + Assert.True(secondIndex > firstIndex, $"Expected '{first}' before '{second}'."); + } +}