Skip to content

Commit 9b358c3

Browse files
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 <suportewmit@gmail.com>
1 parent 99d8fd4 commit 9b358c3

3 files changed

Lines changed: 98 additions & 59 deletions

File tree

src/OpenClaw.Tray.WinUI/App.xaml.cs

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ public IntPtr GetHubWindowHandle()
143143
private GatewayService? _gatewayService;
144144
private CancellationTokenSource? _deepLinkCts;
145145
private bool _isExiting;
146-
146+
147147
/// <summary>
148148
/// Cached connection status — sole writer is OnManagerStateChanged.
149149
/// Reads are safe from any thread; derives from the connection manager's state machine.
@@ -176,10 +176,9 @@ public IntPtr GetHubWindowHandle()
176176
private DiagnosticsClipboardService? _diagnosticsClipboard;
177177
private ToastService? _toastService;
178178
private AppNotificationService? _appNotificationService;
179-
179+
180180
// Node service (optional, enabled in settings)
181181
private NodeService? _nodeService;
182-
183182
// Keep-alive window to anchor WinUI runtime (prevents GC/threading issues)
184183
private Window? _keepAliveWindow;
185184
private SetupWindow? _setupWindow;
@@ -233,10 +232,10 @@ public App()
233232
GatewayHostAccessLocalization.Format = (key, args) => LocalizationHelper.Format(key, args);
234233

235234
InitializeComponent();
236-
235+
237236
s_runMarker.Check();
238237
s_runMarker.MarkStarted();
239-
238+
240239
// Hook up crash handlers
241240
this.UnhandledException += OnUnhandledException;
242241
AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException;
@@ -294,26 +293,15 @@ private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExce
294293

295294
private void OnDomainUnhandledException(object sender, System.UnhandledExceptionEventArgs e)
296295
{
297-
var ex = e.ExceptionObject as Exception;
298-
_crashLogger.Log("DomainUnhandledException", ex);
299-
300-
// Log SherpaOnnx finalizer errors clearly so they are visible in diagnostics.
301-
// The actual crash prevention is handled by the exe.config
302-
// legacyUnhandledExceptionPolicy setting and the GC.SuppressFinalize
303-
// call in PiperTextToSpeechClient.Dispose().
304-
if (ex is DllNotFoundException dllEx &&
305-
dllEx.Message.Contains("sherpa-onnx", StringComparison.OrdinalIgnoreCase))
306-
{
307-
Logger.Warn($"SherpaOnnx native DLL unavailable: {dllEx.Message}");
308-
}
296+
_crashLogger.Log("DomainUnhandledException", e.ExceptionObject as Exception);
309297
}
310298

311299
private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
312300
{
313301
_crashLogger.Log("UnobservedTaskException", e.Exception);
314302
e.SetObserved(); // Prevent crash
315303
}
316-
304+
317305
private void OnProcessExit(object? sender, EventArgs e)
318306
{
319307
s_runMarker.MarkEnded();
@@ -688,7 +676,7 @@ private void InitializeKeepAliveWindow()
688676
_keepAliveWindow = new Window();
689677
_keepAliveWindow.Content = new Microsoft.UI.Xaml.Controls.Grid();
690678
_keepAliveWindow.AppWindow.IsShownInSwitchers = false;
691-
679+
692680
// Move off-screen and set minimal size
693681
_keepAliveWindow.AppWindow.MoveAndResize(new global::Windows.Graphics.RectInt32(-32000, -32000, 1, 1));
694682
}
@@ -697,10 +685,10 @@ private void InitializeTrayIcon()
697685
{
698686
// Initialize keep-alive window first to anchor WinUI runtime
699687
InitializeKeepAliveWindow();
700-
688+
701689
// Pre-create tray menu window at startup to avoid creation crashes later
702690
InitializeTrayMenuWindow();
703-
691+
704692
var iconPath = IconHelper.GetStatusIconPath(ConnectionStatus.Disconnected);
705693
_trayIcon = new TrayIcon(1, iconPath, BuildTrayTooltip());
706694
_trayIcon.IsVisible = true;
@@ -1011,15 +999,15 @@ private void OnTrayMenuItemClicked(object? sender, string action)
1011999
break;
10121000
}
10131001
}
1014-
1002+
10151003
private void CopyDeviceIdToClipboard()
10161004
{
10171005
if (_nodeService?.FullDeviceId == null) return;
1018-
1006+
10191007
try
10201008
{
10211009
CopyTextToClipboard(_nodeService.FullDeviceId);
1022-
1010+
10231011
// Show toast confirming copy
10241012
_toastService!.ShowToast(new ToastContentBuilder()
10251013
.AddText(LocalizationHelper.GetString("Toast_DeviceIdCopied"))
@@ -2040,15 +2028,15 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status)
20402028
{
20412029
Logger.Info($"Node status: {status}");
20422030
AddRecentActivity($"Node mode {status}", category: "node", dashboardPath: "nodes");
2043-
2031+
20442032
// In node-only mode, surface node connection in main status indicator
20452033
if (_settings?.EnableNodeMode == true)
20462034
{
20472035
// Status field is maintained by OnManagerStateChanged — no write needed here.
20482036
UpdateTrayIcon();
20492037
OnUiThread(UpdateStatusDetailWindow);
20502038
}
2051-
2039+
20522040
// Don't show "connected" toast if waiting for pairing - we'll show pairing status instead
20532041
var nodeService = _nodeService;
20542042
if (status == ConnectionStatus.Connected && nodeService?.IsPaired == true)
@@ -2079,7 +2067,7 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status)
20792067
private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatusEventArgs args)
20802068
{
20812069
Logger.Info($"Pairing status: {args.Status}");
2082-
2070+
20832071
try
20842072
{
20852073
if (args.Status == OpenClaw.Shared.PairingStatus.Pending)
@@ -2181,7 +2169,7 @@ public void ShowPairingPendingNotification(string deviceId, string? approvalComm
21812169
"node-pairing-pending",
21822170
deviceId);
21832171
}
2184-
2172+
21852173
private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabilities.SystemNotifyArgs args)
21862174
{
21872175
AddRecentActivity(args.Title, category: "node", dashboardPath: "nodes", details: args.Body);
@@ -3295,7 +3283,7 @@ private async Task ToggleChannelAsync(string channelName)
32953283
await client.StartChannelAsync(channelName);
32963284
AddRecentActivity($"Started channel: {channelName}", category: "channel", dashboardPath: "settings");
32973285
}
3298-
3286+
32993287
// Refresh health
33003288
await RunHealthCheckAsync();
33013289
}
@@ -3407,7 +3395,7 @@ private void StartDeepLinkServer()
34073395
{
34083396
_deepLinkCts = new CancellationTokenSource();
34093397
var token = _deepLinkCts.Token;
3410-
3398+
34113399
Task.Run(async () =>
34123400
{
34133401
while (!token.IsCancellationRequested)

src/OpenClaw.Tray.WinUI/Services/TextToSpeech/PiperTextToSpeechClient.cs

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.IO;
3+
using System.Runtime.InteropServices;
34
using System.Threading;
45
using System.Threading.Tasks;
56
using OpenClaw.Shared;
@@ -23,15 +24,18 @@ namespace OpenClawTray.Services;
2324
/// </summary>
2425
public sealed class PiperTextToSpeechClient : IDisposable
2526
{
27+
private const string SherpaNativeLibrary = "sherpa-onnx-c-api";
28+
private static readonly object s_nativeLibraryLock = new();
29+
private static IntPtr s_nativeLibraryHandle;
30+
2631
private readonly IOpenClawLogger _logger;
2732
private readonly string _voiceId;
28-
private OfflineTts? _tts;
33+
private readonly OfflineTts _tts;
2934
private readonly SemaphoreSlim _gate = new(1, 1);
3035
private bool _disposed;
31-
private bool _ttsAvailable;
3236

3337
public string VoiceId => _voiceId;
34-
public int SampleRate => _tts?.SampleRate ?? 22050;
38+
public int SampleRate => _tts.SampleRate;
3539

3640
public PiperTextToSpeechClient(IOpenClawLogger logger, PiperVoiceManager voices, string voiceId)
3741
{
@@ -54,18 +58,9 @@ public PiperTextToSpeechClient(IOpenClawLogger logger, PiperVoiceManager voices,
5458
config.Model.Debug = 0;
5559
config.MaxNumSentences = 2;
5660

57-
try
58-
{
59-
_tts = new OfflineTts(config);
60-
_ttsAvailable = true;
61-
_logger.Info($"Piper voice '{_voiceId}' loaded (sample rate {_tts.SampleRate} Hz, {config.Model.NumThreads} threads)");
62-
}
63-
catch (DllNotFoundException ex)
64-
{
65-
_logger.Warn($"Piper voice '{_voiceId}' unavailable: sherpa-onnx native library could not be loaded. TTS will be disabled. ({ex.Message})");
66-
_tts = null;
67-
_ttsAvailable = false;
68-
}
61+
EnsureNativeLibraryLoaded();
62+
_tts = new OfflineTts(config);
63+
_logger.Info($"Piper voice '{_voiceId}' loaded (sample rate {_tts.SampleRate} Hz, {config.Model.NumThreads} threads)");
6964
}
7065

7166
/// <summary>
@@ -76,8 +71,6 @@ public async Task<byte[]> GenerateWavAsync(string text, float speed = 1.0f, Canc
7671
{
7772
if (_disposed) throw new ObjectDisposedException(nameof(PiperTextToSpeechClient));
7873
if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("text must be non-empty", nameof(text));
79-
if (!_ttsAvailable || _tts == null)
80-
throw new InvalidOperationException("Piper TTS is not available: sherpa-onnx native library could not be loaded.");
8174

8275
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
8376
try
@@ -139,23 +132,33 @@ private static byte[] ConvertFloatPcmToWav(float[] samples, int sampleRate)
139132
return ms.ToArray();
140133
}
141134

142-
public void Dispose()
135+
private static void EnsureNativeLibraryLoaded()
143136
{
144-
if (_disposed) return;
145-
_disposed = true;
146-
// slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved.
147-
try
137+
lock (s_nativeLibraryLock)
148138
{
149-
if (_tts != null)
139+
if (s_nativeLibraryHandle != IntPtr.Zero)
140+
return;
141+
142+
if (!NativeLibrary.TryLoad(
143+
SherpaNativeLibrary,
144+
typeof(OfflineTts).Assembly,
145+
DllImportSearchPath.SafeDirectories,
146+
out s_nativeLibraryHandle))
150147
{
151-
_tts.Dispose();
152-
// CRITICAL: Suppress the finalizer to prevent SherpaOnnxDestroyOfflineTts
153-
// from being called during GC, which crashes the app when the native DLL
154-
// is unavailable (DllNotFoundException in finalizer = instant crash).
155-
GC.SuppressFinalize(_tts);
148+
throw new DllNotFoundException("Piper TTS native library could not be loaded.");
156149
}
157150
}
158-
catch { /* swallow */ }
151+
}
152+
153+
public void Dispose()
154+
{
155+
if (_disposed) return;
156+
_disposed = true;
157+
// SherpaOnnx suppresses its finalizer only after native cleanup succeeds.
158+
// Suppress first so a cleanup failure cannot retry from the finalizer thread.
159+
GC.SuppressFinalize(_tts);
160+
// slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved.
161+
try { _tts.Dispose(); } catch { /* swallow */ }
159162
// slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved.
160163
try { _gate.Dispose(); } catch { /* swallow */ }
161164
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
namespace OpenClaw.Tray.Tests;
2+
3+
public sealed class PiperTextToSpeechClientContractTests
4+
{
5+
private static string ReadSource() =>
6+
File.ReadAllText(Path.Combine(
7+
TestRepositoryPaths.GetRepositoryRoot(),
8+
"src",
9+
"OpenClaw.Tray.WinUI",
10+
"Services",
11+
"TextToSpeech",
12+
"PiperTextToSpeechClient.cs"));
13+
14+
[Fact]
15+
public void NativeLibrary_IsLoadedBeforeFinalizableWrapperConstruction()
16+
{
17+
var source = ReadSource();
18+
19+
AssertInOrder(
20+
source,
21+
"EnsureNativeLibraryLoaded();",
22+
"_tts = new OfflineTts(config);");
23+
Assert.Contains("NativeLibrary.TryLoad(", source);
24+
Assert.Contains("typeof(OfflineTts).Assembly", source);
25+
Assert.Contains("DllImportSearchPath.SafeDirectories", source);
26+
Assert.Contains("out s_nativeLibraryHandle", source);
27+
}
28+
29+
[Fact]
30+
public void Dispose_SuppressesFinalizerBeforeNativeCleanup()
31+
{
32+
var source = ReadSource();
33+
34+
AssertInOrder(
35+
source,
36+
"GC.SuppressFinalize(_tts);",
37+
"_tts.Dispose();");
38+
}
39+
40+
private static void AssertInOrder(string source, string first, string second)
41+
{
42+
var firstIndex = source.IndexOf(first, StringComparison.Ordinal);
43+
var secondIndex = source.IndexOf(second, StringComparison.Ordinal);
44+
45+
Assert.True(firstIndex >= 0, $"Missing expected source fragment: {first}");
46+
Assert.True(secondIndex > firstIndex, $"Expected '{first}' before '{second}'.");
47+
}
48+
}

0 commit comments

Comments
 (0)