diff --git a/CHANGELOG.md b/CHANGELOG.md index ae68fdbfdc..d43f2d28ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixes +- Prevent Native EXC_BAD_ACCESS signal errors from being captured when managed NullRefrenceExceptions occur ([#3909](https://github.com/getsentry/sentry-dotnet/pull/3909)) - Fixed duplicate SentryMauiEventProcessors ([#3905](https://github.com/getsentry/sentry-dotnet/pull/3905)) ### Dependencies diff --git a/samples/Sentry.Samples.Ios/AppDelegate.cs b/samples/Sentry.Samples.Ios/AppDelegate.cs index 8ba822292f..80e9ea09a5 100644 --- a/samples/Sentry.Samples.Ios/AppDelegate.cs +++ b/samples/Sentry.Samples.Ios/AppDelegate.cs @@ -1,3 +1,5 @@ +using ObjCRuntime; + namespace Sentry.Samples.Ios; [Register("AppDelegate")] @@ -16,6 +18,7 @@ public override bool FinishedLaunching(UIApplication application, NSDictionary l { options.Dsn = "https://eb18e953812b41c3aeb042e666fd3b5c@o447951.ingest.sentry.io/5428537"; options.Debug = true; + options.SampleRate = 1.0F; options.TracesSampleRate = 1.0; options.ProfilesSampleRate = 1.0; @@ -23,77 +26,114 @@ public override bool FinishedLaunching(UIApplication application, NSDictionary l // https://docs.sentry.io/platforms/apple/guides/ios/configuration/ // Enable Native iOS SDK App Hangs detection options.Native.EnableAppHangTracking = true; + + options.CacheDirectoryPath = Path.GetTempPath(); }); // create a new window instance based on the screen size Window = new UIWindow(UIScreen.MainScreen.Bounds); - // determine the background color for the view (SystemBackground requires iOS >= 13.0) + // determine control colours (SystemBackground requires iOS >= 13.0) var backgroundColor = UIDevice.CurrentDevice.CheckSystemVersion(13, 0) #pragma warning disable CA1416 ? UIColor.SystemBackground #pragma warning restore CA1416 : UIColor.White; + var buttonConfig = UIButtonConfiguration.TintedButtonConfiguration; + var terminalButtonConfig = UIButtonConfiguration.TintedButtonConfiguration; + terminalButtonConfig.BaseBackgroundColor = UIColor.SystemRed; - // create a UIViewController with a single UILabel var vc = new UIViewController(); - vc.View!.AddSubview(new UILabel(Window!.Frame) + + var label = new UILabel { BackgroundColor = backgroundColor, TextAlignment = UITextAlignment.Center, Text = "Hello, iOS!", + AutoresizingMask = UIViewAutoresizing.All + }; + + // UIButton for a managed exception that we'll catch and handle (won't crash the app) + var managedCrashButton = new UIButton(UIButtonType.RoundedRect) + { AutoresizingMask = UIViewAutoresizing.All, - }); - Window.RootViewController = vc; + Configuration = buttonConfig + }; + managedCrashButton.SetTitle("Managed Crash", UIControlState.Normal); + managedCrashButton.TouchUpInside += delegate + { + Console.WriteLine("Managed Crash button clicked!"); + try + { + throw new Exception("Catch this!"); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + } + }; - // make the window visible - Window.MakeKeyAndVisible(); + // UIButton for unhandled managed exception + var unhandledCrashButton = new UIButton(UIButtonType.RoundedRect) + { + AutoresizingMask = UIViewAutoresizing.All, + Configuration = terminalButtonConfig + }; + unhandledCrashButton.SetTitle("Unhandled Crash", UIControlState.Normal); + unhandledCrashButton.TouchUpInside += delegate + { + Console.WriteLine("Unhandled Crash button clicked!"); + string s = null!; + // This will cause a NullReferenceException that will crash the app before Sentry can send the event. + // Since we're using a caching transport though, the exception will be written to disk and sent the + // next time the app is launched. + Console.WriteLine("Length: {0}", s.Length); + }; + // UIButton for native crash + var nativeCrashButton = new UIButton(UIButtonType.System) + { + Configuration = terminalButtonConfig + }; + nativeCrashButton.SetTitle("Native Crash", UIControlState.Normal); + nativeCrashButton.TouchUpInside += delegate + { + Console.WriteLine("Native Crash button clicked!"); +#pragma warning disable CS0618 // Type or member is obsolete + // This will cause a native crash that will crash the application before + // Sentry gets a chance to send the event. Since we've enabled caching however, + // the event will be written to disk and sent the next time the app is launched. + SentrySdk.CauseCrash(CrashType.Native); +#pragma warning restore CS0618 // Type or member is obsolete + }; - // Try out the Sentry SDK - SentrySdk.CaptureMessage("From iOS"); + // create a UIStackView to hold the label and buttons + var stackView = new UIStackView(new UIView[] { label, managedCrashButton, unhandledCrashButton, nativeCrashButton }) + { + Axis = UILayoutConstraintAxis.Vertical, + Distribution = UIStackViewDistribution.FillEqually, + Alignment = UIStackViewAlignment.Center, + Spacing = 10, + TranslatesAutoresizingMaskIntoConstraints = false, + }; - // Uncomment to try these - // throw new Exception("Test Unhandled Managed Exception"); - // SentrySdk.CauseCrash(CrashType.Native); + // add the stack view to the view controller's view + vc.View!.BackgroundColor = backgroundColor; + vc.View.AddSubview(stackView); - { - var tx = SentrySdk.StartTransaction("app", "run"); - var count = 10; - for (var i = 0; i < count; i++) - { - FindPrimeNumber(100000); - } + // set constraints for the stack view + NSLayoutConstraint.ActivateConstraints([ + stackView.CenterXAnchor.ConstraintEqualTo(vc.View.CenterXAnchor), + stackView.CenterYAnchor.ConstraintEqualTo(vc.View.CenterYAnchor), + stackView.WidthAnchor.ConstraintEqualTo(vc.View.WidthAnchor, 0.8f), + stackView.HeightAnchor.ConstraintEqualTo(vc.View.HeightAnchor, 0.5f) + ]); - tx.Finish(); - } + Window.RootViewController = vc; - return true; - } + // make the window visible + Window.MakeKeyAndVisible(); - private static long FindPrimeNumber(int n) - { - int count = 0; - long a = 2; - while (count < n) - { - long b = 2; - int prime = 1;// to check if found a prime - while (b * b <= a) - { - if (a % b == 0) - { - prime = 0; - break; - } - b++; - } - if (prime > 0) - { - count++; - } - a++; - } - return (--a); + return true; } } diff --git a/samples/Sentry.Samples.Ios/Info.plist b/samples/Sentry.Samples.Ios/Info.plist index fb0738075a..38f2e8adc8 100644 --- a/samples/Sentry.Samples.Ios/Info.plist +++ b/samples/Sentry.Samples.Ios/Info.plist @@ -2,43 +2,43 @@ - CFBundleDisplayName - Sentry.Samples.Ios - CFBundleIdentifier - io.sentry.dotnet.samples.ios - CFBundleShortVersionString - 1.0 - MinimumOSVersion - 11.0 - CFBundleVersion - 1.0 - LSRequiresIPhoneOS - - UIDeviceFamily - - 1 - 2 - - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - XSAppIconAssets - Assets.xcassets/AppIcon.appiconset + CFBundleDisplayName + Sentry.Samples.Ios + CFBundleIdentifier + io.sentry.dotnet.samples.ios + CFBundleShortVersionString + 1.0 + MinimumOSVersion + 15.0 + CFBundleVersion + 1.0 + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/AppIcon.appiconset diff --git a/samples/Sentry.Samples.Ios/Sentry.Samples.Ios.csproj b/samples/Sentry.Samples.Ios/Sentry.Samples.Ios.csproj index 9ecfce8114..76b122d042 100644 --- a/samples/Sentry.Samples.Ios/Sentry.Samples.Ios.csproj +++ b/samples/Sentry.Samples.Ios/Sentry.Samples.Ios.csproj @@ -1,13 +1,15 @@ - net8.0-ios17.0 + net9.0-ios18.0 Exe enable true - 11.0 + 15.0 true true + + IL3050;IL3053 diff --git a/src/Sentry/Platforms/Cocoa/RuntimeAdapter.cs b/src/Sentry/Platforms/Cocoa/RuntimeAdapter.cs new file mode 100644 index 0000000000..9bc7551cd4 --- /dev/null +++ b/src/Sentry/Platforms/Cocoa/RuntimeAdapter.cs @@ -0,0 +1,29 @@ +using ObjCRuntime; + +namespace Sentry.Cocoa; + +internal interface IRuntime +{ + internal event MarshalManagedExceptionHandler MarshalManagedException; + internal event MarshalObjectiveCExceptionHandler MarshalObjectiveCException; +} + +internal sealed class RuntimeAdapter : IRuntime +{ + public static RuntimeAdapter Instance { get; } = new(); + + private RuntimeAdapter() + { + Runtime.MarshalManagedException += OnMarshalManagedException; + Runtime.MarshalObjectiveCException += OnMarshalObjectiveCException; + } + + public event MarshalManagedExceptionHandler? MarshalManagedException; + public event MarshalObjectiveCExceptionHandler? MarshalObjectiveCException; + + [SecurityCritical] + private void OnMarshalManagedException(object sender, MarshalManagedExceptionEventArgs e) => MarshalManagedException?.Invoke(this, e); + + [SecurityCritical] + private void OnMarshalObjectiveCException(object sender, MarshalObjectiveCExceptionEventArgs e) => MarshalObjectiveCException?.Invoke(this, e); +} diff --git a/src/Sentry/Platforms/Cocoa/RuntimeMarshalManagedExceptionIntegration.cs b/src/Sentry/Platforms/Cocoa/RuntimeMarshalManagedExceptionIntegration.cs new file mode 100644 index 0000000000..396221cb79 --- /dev/null +++ b/src/Sentry/Platforms/Cocoa/RuntimeMarshalManagedExceptionIntegration.cs @@ -0,0 +1,48 @@ +using ObjCRuntime; +using Sentry.Extensibility; +using Sentry.Integrations; + +namespace Sentry.Cocoa; + +/// +/// When AOT Compiling iOS applications, the AppDomain UnhandledExceptionHandler doesn't fire. So instead we intercept +/// the Runtime.RuntimeMarshalManagedException event. +/// +internal class RuntimeMarshalManagedExceptionIntegration : ISdkIntegration +{ + private readonly IRuntime _runtime; + private IHub? _hub; + private SentryOptions? _options; + + internal RuntimeMarshalManagedExceptionIntegration(IRuntime? runtime = null) + => _runtime = runtime ?? RuntimeAdapter.Instance; + + public void Register(IHub hub, SentryOptions options) + { + _hub = hub; + _options = options; + _runtime.MarshalManagedException += Handle; + } + + // Internal for testability + [SecurityCritical] + internal void Handle(object sender, MarshalManagedExceptionEventArgs e) + { + _options?.LogDebug("Runtime Marshal Managed Exception mode {0}", e.ExceptionMode.ToString("G")); + + if (e.Exception is { } ex) + { + ex.SetSentryMechanism( + "Runtime.MarshalManagedException", + "This exception was caught by the .NET Runtime Marshal Managed Exception global error handler. " + + "The application may have crashed as a result of this exception.", + handled: false); + + // Call the internal implementation, so that we still capture even if the hub has been disabled. + _hub?.CaptureExceptionInternal(ex); + + // This is likely a terminal exception so try to send the crash report before shutting down + _hub?.Flush(); + } + } +} diff --git a/src/Sentry/Platforms/Cocoa/SentrySdk.cs b/src/Sentry/Platforms/Cocoa/SentrySdk.cs index c7b438f12d..dcc8e48006 100644 --- a/src/Sentry/Platforms/Cocoa/SentrySdk.cs +++ b/src/Sentry/Platforms/Cocoa/SentrySdk.cs @@ -10,11 +10,6 @@ public static partial class SentrySdk private static void InitSentryCocoaSdk(SentryOptions options) { options.LogDebug("Initializing native SDK"); - // Workaround for https://github.com/xamarin/xamarin-macios/issues/15252 - ObjCRuntime.Runtime.MarshalManagedException += (_, args) => - { - args.ExceptionMode = ObjCRuntime.MarshalManagedExceptionMode.UnwindNativeCode; - }; // Set default release and distribution options.Release ??= GetDefaultReleaseString(); @@ -153,10 +148,6 @@ private static void InitSentryCocoaSdk(SentryOptions options) // When we have an unhandled managed exception, we send that to Sentry twice - once managed and once native. // The managed exception is what a .NET developer would expect, and it is sent by the Sentry.NET SDK // But we also get a native SIGABRT since it crashed the application, which is sent by the Sentry Cocoa SDK. - // This is partially due to our setting ObjCRuntime.MarshalManagedExceptionMode.UnwindNativeCode above. - // Thankfully, we can see Xamarin's unhandled exception handler on the stack trace, so we can filter them out. - // Here is the function that calls abort(), which we will use as a filter: - // https://github.com/xamarin/xamarin-macios/blob/c55fbdfef95028ba03d0f7a35aebca03bd76f852/runtime/runtime.m#L1114-L1122 nativeOptions.BeforeSend = evt => { // There should only be one exception on the event in this case @@ -164,10 +155,25 @@ private static void InitSentryCocoaSdk(SentryOptions options) { // It will match the following characteristics var ex = evt.Exceptions[0]; + + // Thankfully, sometimes we can see Xamarin's unhandled exception handler on the stack trace, so we can filter + // them out. Here is the function that calls abort(), which we will use as a filter: + // https://github.com/xamarin/xamarin-macios/blob/c55fbdfef95028ba03d0f7a35aebca03bd76f852/runtime/runtime.m#L1114-L1122 if (ex.Type == "SIGABRT" && ex.Value == "Signal 6, Code 0" && ex.Stacktrace?.Frames.Any(f => f.Function == "xamarin_unhandled_exception_handler") is true) { - // Don't sent it + // Don't send it + options.LogDebug("Discarded {0} error ({1}). Captured as managed exception instead.", ex.Type, ex.Value); + return null!; + } + + // Similar workaround for NullReferenceExceptions. We don't have any easy way to know whether the + // exception is managed code (compiled to native) or original native code though. + // See: https://github.com/getsentry/sentry-dotnet/issues/3776 + if (ex.Type == "EXC_BAD_ACCESS") + { + // Don't send it + options.LogDebug("Discarded {0} error ({1}). Captured as managed exception instead.", ex.Type, ex.Value); return null!; } } diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index de8d930066..7c99988acf 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -18,6 +18,11 @@ using Sentry.Android.AssemblyReader; #endif +#if IOS || MACCATALYST +using ObjCRuntime; +using Sentry.Cocoa; +#endif + namespace Sentry; /// @@ -162,10 +167,17 @@ internal IEnumerable Integrations yield return new AutoSessionTrackingIntegration(); } +#if IOS || MACCATALYST + if ((_defaultIntegrations & DefaultIntegrations.RuntimeMarshalManagedExceptionIntegration) != 0) + { + yield return new RuntimeMarshalManagedExceptionIntegration(); + } +#else if ((_defaultIntegrations & DefaultIntegrations.AppDomainUnhandledExceptionIntegration) != 0) { yield return new AppDomainUnhandledExceptionIntegration(); } +#endif if ((_defaultIntegrations & DefaultIntegrations.AppDomainProcessExitIntegration) != 0) { @@ -1239,7 +1251,11 @@ public SentryOptions() _integrations = new(); _defaultIntegrations = DefaultIntegrations.AutoSessionTrackingIntegration | +#if IOS || MACCATALYST + DefaultIntegrations.RuntimeMarshalManagedExceptionIntegration | +#else DefaultIntegrations.AppDomainUnhandledExceptionIntegration | +#endif DefaultIntegrations.AppDomainProcessExitIntegration | DefaultIntegrations.AutoSessionTrackingIntegration | DefaultIntegrations.UnobservedTaskExceptionIntegration @@ -1663,11 +1679,19 @@ public void ApplyDefaultTags(IHasTags hasTags) public void DisableDuplicateEventDetection() => RemoveEventProcessor(); +#if IOS || MACCATALYST + /// + /// Disables the capture of errors through . + /// + public void DisableRuntimeMarshalManagedExceptionCapture() => + RemoveDefaultIntegration(DefaultIntegrations.RuntimeMarshalManagedExceptionIntegration); +#else /// /// Disables the capture of errors through . /// public void DisableAppDomainUnhandledExceptionCapture() => RemoveDefaultIntegration(DefaultIntegrations.AppDomainUnhandledExceptionIntegration); +#endif #if HAS_DIAGNOSTIC_INTEGRATION /// @@ -1725,7 +1749,11 @@ public void DisableSystemDiagnosticsMetricsIntegration() internal enum DefaultIntegrations { AutoSessionTrackingIntegration = 1 << 0, +#if IOS || MACCATALYST + RuntimeMarshalManagedExceptionIntegration = 1 << 1, +#else AppDomainUnhandledExceptionIntegration = 1 << 1, +#endif AppDomainProcessExitIntegration = 1 << 2, UnobservedTaskExceptionIntegration = 1 << 3, #if NETFRAMEWORK diff --git a/test/Sentry.Tests/Platforms/iOS/RuntimeMarshalManagedExceptionIntegrationTests.cs b/test/Sentry.Tests/Platforms/iOS/RuntimeMarshalManagedExceptionIntegrationTests.cs new file mode 100644 index 0000000000..5ba83b0c28 --- /dev/null +++ b/test/Sentry.Tests/Platforms/iOS/RuntimeMarshalManagedExceptionIntegrationTests.cs @@ -0,0 +1,70 @@ +#if IOS +using ObjCRuntime; +using Sentry.Cocoa; + +namespace Sentry.Tests.Platforms.iOS; + +public class RuntimeMarshalManagedExceptionIntegrationTests +{ + private class Fixture + { + public IHub Hub { get; } = Substitute.For(); + public IRuntime Runtime { get; } = Substitute.For(); + + public RuntimeMarshalManagedExceptionIntegration GetSut() => new(Runtime); + } + + private readonly Fixture _fixture = new(); + private SentryOptions SentryOptions { get; } = new(); + + [Fact] + public void Handle_WithException_CaptureEvent() + { + var sut = _fixture.GetSut(); + sut.Register(_fixture.Hub, SentryOptions); + + sut.Handle(this, new MarshalManagedExceptionEventArgs { Exception = new Exception() }); + + _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); + } + + [Fact] + public void Handle_WithException_IsHandledFalse() + { + var sut = _fixture.GetSut(); + sut.Register(_fixture.Hub, SentryOptions); + + var exception = new Exception(); + sut.Handle(this, new MarshalManagedExceptionEventArgs { Exception = exception }); + Assert.Equal(false, exception.Data[Mechanism.HandledKey]); + Assert.True(exception.Data.Contains(Mechanism.MechanismKey)); + + var stackTraceFactory = Substitute.For(); + var exceptionProcessor = new MainExceptionProcessor(SentryOptions, () => stackTraceFactory); + var @event = new SentryEvent(exception); + + exceptionProcessor.Process(exception, @event); + Assert.NotNull(@event.SentryExceptions?.ToList().Single(p => p.Mechanism?.Handled == false)); + } + + [Fact] + public void Handle_NoException_NoCaptureEvent() + { + var sut = _fixture.GetSut(); + sut.Register(_fixture.Hub, SentryOptions); + + sut.Handle(this, new MarshalManagedExceptionEventArgs()); + + _fixture.Hub.DidNotReceive().CaptureEvent(Arg.Any()); + } + + [Fact] + public void Register_UnhandledException_Subscribes() + { + var sut = _fixture.GetSut(); + sut.Register(_fixture.Hub, SentryOptions); + + _fixture.Runtime.Received().MarshalManagedException += sut.Handle; + } +} +#endif diff --git a/test/Sentry.Tests/SentryOptionsTests.cs b/test/Sentry.Tests/SentryOptionsTests.cs index cd20a5c7d3..7a12849443 100644 --- a/test/Sentry.Tests/SentryOptionsTests.cs +++ b/test/Sentry.Tests/SentryOptionsTests.cs @@ -1,6 +1,8 @@ namespace Sentry.Tests; #if NETFRAMEWORK using Sentry.PlatformAbstractions; +#elif IOS || MACCATALYST +using Sentry.Cocoa; #endif public partial class SentryOptionsTests { @@ -285,6 +287,16 @@ public void DisableNetFxInstallationsEventProcessor_RemovesDisableNetFxInstallat } #endif +#if IOS || MACCATALYST + [Fact] + public void DisableRuntimeMarshalManagedExceptionCapture_RemovesRuntimeMarshalManagedExceptionIntegration() + { + var sut = new SentryOptions(); + sut.DisableRuntimeMarshalManagedExceptionCapture(); + Assert.DoesNotContain(sut.Integrations, + p => p is RuntimeMarshalManagedExceptionIntegration); + } +#else [Fact] public void DisableAppDomainUnhandledExceptionCapture_RemovesAppDomainUnhandledExceptionIntegration() { @@ -293,6 +305,7 @@ public void DisableAppDomainUnhandledExceptionCapture_RemovesAppDomainUnhandledE Assert.DoesNotContain(sut.Integrations, p => p is AppDomainUnhandledExceptionIntegration); } +#endif [Fact] public void DisableTaskUnobservedTaskExceptionCapture_UnobservedTaskExceptionIntegration() @@ -581,12 +594,21 @@ public void GetAllEventProcessors_NoAdding_FirstReturned_DuplicateDetectionProce _ = Assert.IsType(sut.GetAllEventProcessors().First()); } +#if IOS || MACCATALYST + [Fact] + public void Integrations_Includes_RuntimeMarshalManagedExceptionIntegration() + { + var sut = new SentryOptions(); + Assert.Contains(sut.Integrations, i => i.GetType() == typeof(RuntimeMarshalManagedExceptionIntegration)); + } +#else [Fact] public void Integrations_Includes_AppDomainUnhandledExceptionIntegration() { var sut = new SentryOptions(); Assert.Contains(sut.Integrations, i => i.GetType() == typeof(AppDomainUnhandledExceptionIntegration)); } +#endif [Fact] public void Integrations_Includes_AppDomainProcessExitIntegration()