diff --git a/.github/workflows/test-run-desktop.yml b/.github/workflows/test-run-desktop.yml index 881c8b532..fd7f3243a 100644 --- a/.github/workflows/test-run-desktop.yml +++ b/.github/workflows/test-run-desktop.yml @@ -66,6 +66,8 @@ jobs: - name: Run Integration Tests (macOS) if: inputs.platform == 'macos' timeout-minutes: 20 + env: + SENTRY_TEST_BACKEND: ${{ inputs.backend }} run: | $env:SENTRY_TEST_PLATFORM = "Desktop" $env:SENTRY_TEST_APP = "samples/IntegrationTest/Build/test.app/Contents/MacOS/IntegrationTest" diff --git a/CHANGELOG.md b/CHANGELOG.md index 685a4a1d9..de9c5ad1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - Updated the dependency resolution within the SDK. This enables .NET Standard 2.1 both for building the SDK from source and for setting the Api Compatibility Level in the Player Settings in your project. The assembly aliasing was originally intended to prevent dependency conflicts the SDK might introduce by deeply renaming its bundled dependencies. However, this prevented Unity from resolving these dependencies at build time, creating ambiguity for certain types. To resolve this, the SDK now excludes `System.Buffers`, `System.Memory`, `System.Numerics.Vectors`, and `System.Threading.Tasks.Extensions` from being aliased, letting the Unity build pipeline handle them. ([#2726](https://github.com/getsentry/sentry-unity/pull/2726)) +- Added the experimental `options.Experimental.EnableNativeAppHangTracking` (default `false`) to enable app hang detection via `sentry-native` on macOS, Windows, and Linux. On macOS, this requires the macOS backend to be switched to `Native` instead of `Cocoa` in the Advanced -> Experimental settings. `sentry-native` monitors the main thread and produces an event including a stack trace for the hang, reusing the top-level `AppHangTimeout` (default `5s`). When effective, the Unity SDK's C# watchdog is skipped to avoid duplicate reports. iOS app hang detection remains controlled by the top-level `EnableAppHangTracking`. ([#2709](https://github.com/getsentry/sentry-unity/pull/2709)) ### Dependencies diff --git a/modules/sentry-native b/modules/sentry-native index fea16b84e..68fec63c8 160000 --- a/modules/sentry-native +++ b/modules/sentry-native @@ -1 +1 @@ -Subproject commit fea16b84ef4e723dd3a0e7f5e76f1737973dd349 +Subproject commit 68fec63c89240a415455a2f6d9337e9e49373389 diff --git a/package-dev/Plugins/Switch/sentry_native_stubs.c b/package-dev/Plugins/Switch/sentry_native_stubs.c index a8604748e..15586a5b3 100644 --- a/package-dev/Plugins/Switch/sentry_native_stubs.c +++ b/package-dev/Plugins/Switch/sentry_native_stubs.c @@ -126,6 +126,18 @@ void sentry_options_set_shutdown_timeout(void* options, uint64_t shutdown_timeou (void)shutdown_timeout; } +void sentry_options_set_enable_app_hang_tracking(void* options, int enabled) +{ + (void)options; + (void)enabled; +} + +void sentry_options_set_app_hang_timeout(void* options, uint64_t timeout) +{ + (void)options; + (void)timeout; +} + void sentry_options_set_logger(void* options, void* logger, void* userdata) { (void)options; @@ -315,6 +327,11 @@ void sentry_reinstall_backend(void) /* No-op */ } +void sentry_app_hang_heartbeat(void) +{ + /* No-op */ +} + sentry_value_t sentry_get_modules_list(void) { /* Return null - no modules to report */ diff --git a/src/Sentry.Unity.Editor/ConfigurationWindow/AdvancedTab.cs b/src/Sentry.Unity.Editor/ConfigurationWindow/AdvancedTab.cs index 489f52bcc..9b8b078c7 100644 --- a/src/Sentry.Unity.Editor/ConfigurationWindow/AdvancedTab.cs +++ b/src/Sentry.Unity.Editor/ConfigurationWindow/AdvancedTab.cs @@ -64,14 +64,15 @@ internal static void Display(ScriptableSentryUnityOptions options, SentryCliOpti options.EnableAppHangTracking = EditorGUILayout.Toggle( new GUIContent("Enable", - "Enables app hang detection via the native SDK. Currently effective on iOS only; " + - "no-op on other platforms until each platform's native hang detection lands."), + "Enables app hang detection on iOS via sentry-cocoa. App hang detection on macOS, " + + "Windows, and Linux is experimental and controlled separately in the Experimental section."), options.EnableAppHangTracking); options.AppHangTimeout = EditorGUILayout.IntField( new GUIContent("App Hang Timeout [ms]", "The duration in [ms] for how long the main thread has to be blocked " + - "before an app hang is reported.\nDefault: 5000ms"), + "before an app hang is reported. Shared with the experimental native app hang " + + "detection.\nDefault: 5000ms"), options.AppHangTimeout); options.AppHangTimeout = Math.Max(0, options.AppHangTimeout); } @@ -270,6 +271,14 @@ internal static void Display(ScriptableSentryUnityOptions options, SentryCliOpti "Uploads crashes immediately."), options.Experimental.LinuxBackend); } + + options.Experimental.EnableNativeAppHangTracking = EditorGUILayout.Toggle( + new GUIContent( + "Native App Hang Tracking", + "Enables app hang detection via sentry-native on macOS, Windows, and Linux. Requires the " + + "corresponding platform backend above to be set to 'Native'. Shares the App Hang Timeout " + + "configured in the App Hang Tracking section. iOS is unaffected by this option."), + options.Experimental.EnableNativeAppHangTracking); } EditorGUI.indentLevel--; EditorGUILayout.EndFoldoutHeaderGroup(); diff --git a/src/Sentry.Unity.Native/SentryNative.cs b/src/Sentry.Unity.Native/SentryNative.cs index 0ccade4cc..52e604e82 100644 --- a/src/Sentry.Unity.Native/SentryNative.cs +++ b/src/Sentry.Unity.Native/SentryNative.cs @@ -15,6 +15,7 @@ public static class SentryNative private static bool ShouldReinstallBackend; private static IDiagnosticLogger? Logger; + private static Action? OnQuitting; /// /// Configures the native SDK. @@ -56,11 +57,17 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf return; } - ApplicationAdapter.Instance.Quitting += () => + if (OnQuitting is not null) + { + ApplicationAdapter.Instance.Quitting -= OnQuitting; + } + + OnQuitting = () => { Logger?.LogDebug("Closing the sentry-native SDK"); SentryNativeBridge.Close(); }; + ApplicationAdapter.Instance.Quitting += OnQuitting; options.ScopeObserver = new NativeScopeObserver(options); options.EnableScopeSync = true; options.NativeContextWriter = new NativeContextWriter(); @@ -87,6 +94,22 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf } options.CrashedLastRun = () => crashedLastRun; + if (options.Experimental.EnableNativeAppHangTracking) + { + Logger?.LogDebug("Starting the app-hang heartbeat coroutine."); + SentryMonoBehaviour.Instance.StartAppHangHeartbeat(SentryNativeBridge.AppHangHeartbeat); + + // sentry-native handles app-hang detection on the desktop platforms. Where it is effective, skip the + // C# ANR watchdog so a hang isn't reported twice (mirrors the iOS/sentry-cocoa behavior). + if (platform is RuntimePlatform.OSXPlayer or RuntimePlatform.OSXServer + or RuntimePlatform.WindowsPlayer or RuntimePlatform.WindowsServer + or RuntimePlatform.LinuxPlayer or RuntimePlatform.LinuxServer) + { + Logger?.LogDebug("Disabling the C# ANR watchdog - sentry-native handles app hang detection."); + options.DisableAnrIntegration(); + } + } + ShouldReinstallBackend = true; } diff --git a/src/Sentry.Unity.Native/SentryNativeBridge.cs b/src/Sentry.Unity.Native/SentryNativeBridge.cs index 26f331eea..0919895b5 100644 --- a/src/Sentry.Unity.Native/SentryNativeBridge.cs +++ b/src/Sentry.Unity.Native/SentryNativeBridge.cs @@ -121,6 +121,13 @@ is RuntimePlatform.WindowsPlayer or RuntimePlatform.WindowsServer Logger?.LogInfo("Passing the native logs back to the C# layer is not supported on Mono - skipping native logger."); } + Logger?.LogDebug("Setting EnableNativeAppHangTracking: {0}", options.Experimental.EnableNativeAppHangTracking); + sentry_options_set_enable_app_hang_tracking(cOptions, options.Experimental.EnableNativeAppHangTracking ? 1 : 0); + + var appHangTimeoutMs = (ulong)Math.Max(0, options.AppHangTimeout.TotalMilliseconds); + Logger?.LogDebug("Setting AppHangTimeout: {0}ms", appHangTimeoutMs); + sentry_options_set_app_hang_timeout(cOptions, appHangTimeoutMs); + Logger?.LogDebug("Initializing sentry native"); return 0 == sentry_init(cOptions); } @@ -151,6 +158,8 @@ internal static string GetDatabasePath(SentryUnityOptions options, IApplication? internal static void ReinstallBackend() => sentry_reinstall_backend(); + internal static void AppHangHeartbeat() => sentry_app_hang_heartbeat(); + // libsentry.so [DllImport(SentryLib)] private static extern IntPtr sentry_options_new(); @@ -193,6 +202,12 @@ internal static string GetDatabasePath(SentryUnityOptions options, IApplication? [DllImport(SentryLib)] private static extern void sentry_options_set_enable_metrics(IntPtr options, int enable_metrics); + [DllImport(SentryLib)] + private static extern void sentry_options_set_enable_app_hang_tracking(IntPtr options, int enabled); + + [DllImport(SentryLib)] + private static extern void sentry_options_set_app_hang_timeout(IntPtr options, ulong timeout); + [UnmanagedFunctionPointer(CallingConvention.Cdecl, SetLastError = true)] private delegate void sentry_logger_function_t(int level, IntPtr message, IntPtr argsAddress, IntPtr userData); @@ -354,4 +369,7 @@ private static void WithMarshalledStruct(T structure, Action action) [DllImport(SentryLib)] private static extern void sentry_reinstall_backend(); + + [DllImport(SentryLib)] + private static extern void sentry_app_hang_heartbeat(); } diff --git a/src/Sentry.Unity/ExperimentalSentryUnityOptions.cs b/src/Sentry.Unity/ExperimentalSentryUnityOptions.cs index b05e34d21..bd03043e8 100644 --- a/src/Sentry.Unity/ExperimentalSentryUnityOptions.cs +++ b/src/Sentry.Unity/ExperimentalSentryUnityOptions.cs @@ -38,4 +38,13 @@ public class ExperimentalSentryUnityOptions /// a minimum of 10 seconds so the out-of-process handler has time to flush before the player exits. /// [field: SerializeField] public LinuxBackend LinuxBackend { get; set; } = LinuxBackend.Breakpad; + + /// + /// Enables app hang detection via sentry-native on macOS, Windows, and Linux. Defaults to + /// false. Requires the backend to be switched to + /// on macOS. sentry-native monitors the main thread and + /// produces an app hang event including a stack trace. When enabled, the C# watchdog is skipped to avoid + /// duplicate reports. The timeout is taken from AppHangTimeout. + /// + [field: SerializeField] public bool EnableNativeAppHangTracking { get; set; } = false; } diff --git a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs index 168b8f565..1841c1a65 100644 --- a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs +++ b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs @@ -237,6 +237,7 @@ internal SentryUnityOptions ToSentryUnityOptions( options.Experimental.MacosBackend = Experimental.MacosBackend; options.Experimental.WindowsBackend = Experimental.WindowsBackend; options.Experimental.LinuxBackend = Experimental.LinuxBackend; + options.Experimental.EnableNativeAppHangTracking = Experimental.EnableNativeAppHangTracking; // By default, the cacheDirectoryPath gets set on known platforms. We're overwriting this behaviour here. if (!EnableOfflineCaching) diff --git a/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs b/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs new file mode 100644 index 000000000..80d984f4d --- /dev/null +++ b/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections; +using UnityEngine; + +namespace Sentry.Unity; + +/// +/// Drives the periodic heartbeat used by sentry-native's app-hang detection. +/// The coroutine runs on the Unity main thread, which is the thread the native +/// daemon latches onto as the monitored target. +/// +public partial class SentryMonoBehaviour +{ + private static readonly TimeSpan AppHangHeartbeatInterval = TimeSpan.FromSeconds(1); + + private Coroutine? _appHangHeartbeat; + + /// + /// Starts the app-hang heartbeat on the main thread at a fixed 1-second interval. Arming is + /// deferred until the player loop is running (see ) so + /// the synchronous startup stall isn't reported as a hang. + /// + public Coroutine StartAppHangHeartbeat(Action heartbeat) => + StartAppHangHeartbeat(heartbeat, AppHangHeartbeatInterval); + + // Internal overload so tests can use a short interval. + internal Coroutine StartAppHangHeartbeat(Action heartbeat, TimeSpan interval) + { + if (_appHangHeartbeat is not null) + { + StopCoroutine(_appHangHeartbeat); + } + + _appHangHeartbeat = StartCoroutine(AppHangHeartbeatCoroutine(heartbeat, interval)); + return _appHangHeartbeat; + } + + private IEnumerator AppHangHeartbeatCoroutine(Action heartbeat, TimeSpan interval) + { + // Skipping the first frame. The first heartbeat both latches the main thread as the + // monitored target and arms detection. The monitor no-op without having received a + // heartbeat. During startup, splash screen plus the first scene load routinely block the + // main thread longer than the hang timeout and would cause false positives. + // This also works in batchmode/headless (e.g. LinuxServer), unlike WaitForEndOfFrame. + yield return null; + heartbeat(); + + var wait = new WaitForSecondsRealtime((float)interval.TotalSeconds); + while (true) + { + yield return wait; + heartbeat(); + } + } +} diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 512d92b24..50128d4f2 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -186,9 +186,9 @@ public sealed class SentryUnityOptions : SentryOptions public bool IosWatchdogTerminationIntegrationEnabled { get; set; } = false; /// - /// Enables app hang detection on platforms whose native SDK can deliver Unity-thread hang - /// coverage. Currently effective on iOS only; on other platforms this is a no-op until each - /// platform's native hang detection lands. + /// Enables app hang detection on iOS through sentry-cocoa, which monitors the main thread. + /// App hang detection on macOS, Windows, and Linux is experimental and controlled separately via + /// Experimental.EnableNativeAppHangTracking. /// public bool EnableAppHangTracking { get; set; } = true; diff --git a/test/IntegrationTest/Integration.Tests.ps1 b/test/IntegrationTest/Integration.Tests.ps1 index d4c3048c9..b0b475470 100644 --- a/test/IntegrationTest/Integration.Tests.ps1 +++ b/test/IntegrationTest/Integration.Tests.ps1 @@ -408,3 +408,55 @@ if ($env:SENTRY_TEST_PLATFORM -ne "WebGL") { } } } + +# In-proc app-hang detection is driven by the C# heartbeat coroutine that pings sentry-native, so it +# is active for every desktop configuration that uses the sentry-native code path. The exception +# is macOS with the Cocoa backend: there the SDK uses sentry-cocoa, which has its own app-hang path +# and never starts the in-proc heartbeat, so skip that one configuration. +$isCocoaBackend = $env:SENTRY_TEST_BACKEND -eq "cocoa" +if ($env:SENTRY_TEST_PLATFORM -eq "Desktop" -and -not $isCocoaBackend) { + Describe "Unity $($env:SENTRY_TEST_PLATFORM) App Hang Tests" { + + Context "App Hang Capture" { + BeforeAll { + $script:runEvent = $null + $script:runResult = Invoke-TestAction -Action "app-hang-capture" + + # The native app-hang event is captured in-proc (same run, no relaunch). Its event ID + # is generated natively, so look it up by the unique scope tag the app sets instead. + $hangId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1 + if ($hangId) { + Write-Host "::group::Getting event content" + $script:runEvent = Get-SentryTestEvent -TagName "test.app_hang_id" -TagValue "$hangId" -TimeoutSeconds 300 + Write-Host "::endgroup::" + } + } + + It "" -ForEach $CommonTestCases { + & $testBlock -SentryEvent $runEvent -TestType "app-hang-capture" -RunResult $runResult -TestSetup $script:TestSetup + } + + It "Has error level" { + ($runEvent.tags | Where-Object { $_.key -eq "level" }).value | Should -Be "error" + } + + It "Has AppHang exception with stacktrace" { + $runEvent.exception | Should -Not -BeNullOrEmpty + $runEvent.exception.values | Should -Not -BeNullOrEmpty + $exception = $runEvent.exception.values[0] + $exception | Should -Not -BeNullOrEmpty + $exception.type | Should -Be "AppHang" + $exception.value | Should -Match "App hung for at least" + $exception.stacktrace | Should -Not -BeNullOrEmpty + } + + It "Has AppHang mechanism" { + $mechanism = $runEvent.exception.values[0].mechanism + $mechanism | Should -Not -BeNullOrEmpty + $mechanism.type | Should -Be "AppHang" + $mechanism.handled | Should -Be $true + $mechanism.synthetic | Should -Be $true + } + } + } +} diff --git a/test/Scripts.Integration.Test/Scripts/IntegrationOptionsConfiguration.cs b/test/Scripts.Integration.Test/Scripts/IntegrationOptionsConfiguration.cs index c3f98c136..f52dd4944 100644 --- a/test/Scripts.Integration.Test/Scripts/IntegrationOptionsConfiguration.cs +++ b/test/Scripts.Integration.Test/Scripts/IntegrationOptionsConfiguration.cs @@ -41,6 +41,10 @@ public override void Configure(SentryUnityOptions options) // Disable ANR to avoid test interference options.DisableAnrIntegration(); + // App Hang tracking + options.Experimental.EnableNativeAppHangTracking = true; + options.AppHangTimeout = TimeSpan.FromSeconds(2); + // Runtime initialization for integration tests options.AndroidNativeInitializationType = NativeInitializationType.Runtime; options.IosNativeInitializationType = NativeInitializationType.Runtime; diff --git a/test/Scripts.Integration.Test/Scripts/IntegrationTester.cs b/test/Scripts.Integration.Test/Scripts/IntegrationTester.cs index 5d21ae846..06050d4dc 100644 --- a/test/Scripts.Integration.Test/Scripts/IntegrationTester.cs +++ b/test/Scripts.Integration.Test/Scripts/IntegrationTester.cs @@ -66,6 +66,9 @@ public void Start() case "crash-capture": StartCoroutine(CrashCapture()); break; + case "app-hang-capture": + StartCoroutine(AppHangCapture()); + break; case "crash-send": CrashSend(); break; @@ -211,6 +214,40 @@ private IEnumerator CrashCapture() Application.Quit(1); } + private IEnumerator AppHangCapture() + { + var hangId = Guid.NewGuid().ToString(); + + AddIntegrationTestContext("app-hang-capture"); + + // The native app-hang event is captured in-proc by sentry-native and its event ID is not + // visible to C#. Tag the scope with a unique ID so the test harness can look the event up, + // the same way crash-capture does (scope tags sync to the native layer). + SentrySdk.ConfigureScope(scope => + { + scope.SetTag("test.app_hang_id", hangId); + }); + + // Wait for the scope sync to complete and for the app-hang heartbeat coroutine to arm + // (arming is deliberately deferred by a frame so startup isn't reported as a hang). + yield return new WaitForSeconds(0.5f); + + Logger.Log($"EVENT_CAPTURED: {hangId}"); + Logger.Log("APP HANG TEST: Blocking the main thread to trigger native app-hang detection"); + + // Block the main thread well past AppHangTimeout (2s in IntegrationOptionsConfiguration), + // clearing the watchdog's 500ms poll and 1s heartbeat interval so detection reliably fires. + System.Threading.Thread.Sleep(5000); + + // The main thread is responsive again; let the heartbeat resume and flush the captured event. + yield return null; + + SentrySdk.FlushAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + + Logger.Log("APP HANG TEST: Flush complete, quitting."); + Application.Quit(0); + } + private void CrashSend() { Logger.Log("CrashSend: Initializing Sentry to flush cached crash report..."); diff --git a/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs b/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs new file mode 100644 index 000000000..a96edefaf --- /dev/null +++ b/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections; +using NUnit.Framework; +using Sentry.Unity.Tests.Stubs; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Sentry.Unity.Tests; + +public class SentryMonoBehaviourAppHangTests +{ + private SentryMonoBehaviour GetSut() + { + var gameObject = new GameObject("AppHangTest"); + var sut = gameObject.AddComponent(); + sut.Application = new TestApplication(); + return sut; + } + + [UnityTest] + public IEnumerator StartAppHangHeartbeat_ArmsAfterFirstFrameThenFiresPeriodically() + { + var sut = GetSut(); + var count = 0; + + // Use a tiny interval so the test runs fast. + sut.StartAppHangHeartbeat(() => count++, TimeSpan.FromSeconds(0.05)); + + // Arming is deferred until the player loop ticks, so nothing fires synchronously on start. + Assert.AreEqual(0, count); + + // The first heartbeat fires once a frame has passed. + yield return null; + Assert.GreaterOrEqual(count, 1); + + // After roughly two intervals we expect at least two more. + yield return new WaitForSecondsRealtime(0.12f); + + Assert.GreaterOrEqual(count, 3); + } + + [UnityTest] + public IEnumerator StartAppHangHeartbeat_StopsWhenObjectDestroyed() + { + var sut = GetSut(); + var count = 0; + + sut.StartAppHangHeartbeat(() => count++, TimeSpan.FromSeconds(0.05)); + + // Let it arm (deferred until the player loop ticks). + yield return null; + Assert.GreaterOrEqual(count, 1); + + UnityEngine.Object.DestroyImmediate(sut.gameObject); + var countAfterDestroy = count; + + yield return new WaitForSecondsRealtime(0.12f); + + Assert.AreEqual(countAfterDestroy, count); + } +} diff --git a/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs b/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs index c17f77fdd..75d689b8c 100644 --- a/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs +++ b/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs @@ -156,4 +156,19 @@ public void Options_Experimental_LinuxBackend_IsSettable() options.Experimental.LinuxBackend = LinuxBackend.Native; Assert.AreEqual(LinuxBackend.Native, options.Experimental.LinuxBackend); } + + [Test] + public void Options_Experimental_EnableNativeAppHangTracking_DefaultsToFalse() + { + var options = new SentryUnityOptions(); + Assert.IsFalse(options.Experimental.EnableNativeAppHangTracking); + } + + [Test] + public void Options_Experimental_EnableNativeAppHangTracking_IsSettable() + { + var options = new SentryUnityOptions(); + options.Experimental.EnableNativeAppHangTracking = true; + Assert.IsTrue(options.Experimental.EnableNativeAppHangTracking); + } }