diff --git a/Bugsnag/Assets/Bugsnag/Runtime/INativeClient.cs b/Bugsnag/Assets/Bugsnag/Runtime/INativeClient.cs index a65685db..2a97a222 100644 --- a/Bugsnag/Assets/Bugsnag/Runtime/INativeClient.cs +++ b/Bugsnag/Assets/Bugsnag/Runtime/INativeClient.cs @@ -103,13 +103,7 @@ interface INativeClient : IFeatureFlagStore IDictionary GetNativeMetadata(); - /// - /// Find the native loaded image that corresponds to a native instruction address - /// supplied by il2cpp_native_stack_trace(). - /// - /// The address to find the corresponding image of - /// The corresponding image, or null - LoadedImage FindImageAtAddress(UInt64 address); + StackTraceLine[] ToStackFrames(System.Exception exception); bool ShouldAttemptDelivery(); diff --git a/Bugsnag/Assets/Bugsnag/Runtime/LoadedImage.cs b/Bugsnag/Assets/Bugsnag/Runtime/LoadedImage.cs index 4941e9c9..f31bbc99 100644 --- a/Bugsnag/Assets/Bugsnag/Runtime/LoadedImage.cs +++ b/Bugsnag/Assets/Bugsnag/Runtime/LoadedImage.cs @@ -4,17 +4,19 @@ namespace BugsnagUnity { class LoadedImage { - public LoadedImage(UInt64 loadAddress, UInt64 size, string fileName, string uuid) + public LoadedImage(UInt64 loadAddress, UInt64 size, string fileName, string uuid, bool isMainImage) { LoadAddress = loadAddress; Size = size; FileName = fileName; Uuid = uuid; + IsMainImage = isMainImage; } public readonly UInt64 LoadAddress; public readonly UInt64 Size; public readonly string FileName; public readonly string Uuid; + public readonly bool IsMainImage; } } diff --git a/Bugsnag/Assets/Bugsnag/Runtime/Native/Android/NativeClient.cs b/Bugsnag/Assets/Bugsnag/Runtime/Native/Android/NativeClient.cs index 184d01e7..dad0096f 100644 --- a/Bugsnag/Assets/Bugsnag/Runtime/Native/Android/NativeClient.cs +++ b/Bugsnag/Assets/Bugsnag/Runtime/Native/Android/NativeClient.cs @@ -164,9 +164,9 @@ public void RegisterForOnSessionCallbacks() NativeInterface.RegisterForOnSessionCallbacks(); } - public LoadedImage FindImageAtAddress(UInt64 address) + public StackTraceLine[] ToStackFrames(System.Exception exception) { - return null; + return new StackTraceLine[0]; } } diff --git a/Bugsnag/Assets/Bugsnag/Runtime/Native/Cocoa/LoadedImages.cs b/Bugsnag/Assets/Bugsnag/Runtime/Native/Cocoa/LoadedImages.cs index df29474e..7df4f984 100644 --- a/Bugsnag/Assets/Bugsnag/Runtime/Native/Cocoa/LoadedImages.cs +++ b/Bugsnag/Assets/Bugsnag/Runtime/Native/Cocoa/LoadedImages.cs @@ -5,40 +5,77 @@ namespace BugsnagUnity { + /// + /// Caches the list of loaded images as reported by the native runtime, and provides searching by address. + /// class LoadedImages { /// /// Refresh the list of loaded images to match what the native side currently says. /// + /// The file name of the main image file /// - /// Note: You MUST call this at least once before using an instance of this class! + /// Note: If anything goes wrong during the refresh, the currently cached state won't change. /// - public void Refresh() + #nullable enable + public void Refresh(String? mainImageFileName) { UInt64 loadedNativeImagesAt = NativeCode.bugsnag_lastChangedLoadedImages(); - if (loadedNativeImagesAt == lastLoadedNativeImagesAt) + if (loadedNativeImagesAt == LastLoadedNativeImagesAt) + { + // Only refresh if something changed. + return; + } + + var imageCount = NativeCode.bugsnag_getLoadedImageCount(); + if (imageCount == 0) { return; } - lastLoadedNativeImagesAt = loadedNativeImagesAt; // Ask for the current count * 2 in case new images get added between calls - var nativeImages = new NativeLoadedImage[NativeCode.bugsnag_getLoadedImageCount() * 2]; + var nativeImages = new NativeLoadedImage[imageCount * 2]; var count = NativeCode.bugsnag_getLoadedImages(nativeImages, (UInt64)nativeImages.LongLength); - var images = new LoadedImage[count]; - for (UInt64 i = 0; i < count; i++) + try + { + if (count == 0) + { + return; + } + + UInt64 mainLoadAddress = 0; + var images = new LoadedImage[count]; + for (UInt64 i = 0; i < count; i++) + { + var nativeImage = nativeImages[i]; + var uuid = new byte[16]; + Marshal.Copy(nativeImage.UuidBytes, uuid, 0, 16); + var fileName = Marshal.PtrToStringAnsi(nativeImage.FileName); + var isMainImage = fileName == mainImageFileName; + + var image = new LoadedImage(nativeImage.LoadAddress, + nativeImage.Size, + fileName, + new Guid(uuid).ToString(), + isMainImage); + if (isMainImage) + { + mainLoadAddress = image.LoadAddress; + } + images[i] = image; + } + + // Update cache + Images = images; + MainImageLoadAddress = mainLoadAddress; + LowestImageLoadAddress = images[0].LoadAddress; + LastLoadedNativeImagesAt = loadedNativeImagesAt; + } + finally { - var nativeImage = nativeImages[i]; - var uuid = new byte[16]; - Marshal.Copy(nativeImage.UuidBytes, uuid, 0, 16); - images[i] = new LoadedImage(nativeImage.LoadAddress, - nativeImage.Size, - Marshal.PtrToStringAnsi(nativeImage.FileName), - new Guid(uuid).ToString()); + // bugsnag_getLoadedImages() locks a mutex, so we must call bugsnag_unlockLoadedImages() + NativeCode.bugsnag_unlockLoadedImages(); } - Images = images; - // bugsnag_getLoadedImages() locks a mutex, so we must call bugsnag_unlockLoadedImages() - NativeCode.bugsnag_unlockLoadedImages(); } /// @@ -47,8 +84,14 @@ public void Refresh() /// /// The address to find the corresponding image of /// The corresponding image, or null - public LoadedImage FindImageAtAddress(UInt64 address) + #nullable enable + public LoadedImage? FindImageAtAddress(UInt64 address) { + if (address < LowestImageLoadAddress) + { + address += MainImageLoadAddress; + } + int idx = Array.BinarySearch(Images, address, new AddressToImageComparator()); if (idx < 0) { @@ -57,11 +100,10 @@ public LoadedImage FindImageAtAddress(UInt64 address) return Images[idx]; } - /// - /// The currently loaded images, as of the last call to Refresh(). - /// - public LoadedImage[] Images; - private UInt64 lastLoadedNativeImagesAt = 0; + private LoadedImage[] Images = new LoadedImage[0]; + private UInt64 MainImageLoadAddress = 0; + private UInt64 LowestImageLoadAddress = 0; + private UInt64 LastLoadedNativeImagesAt = 0; private class AddressToImageComparator : IComparer { diff --git a/Bugsnag/Assets/Bugsnag/Runtime/Native/Cocoa/NativeClient.cs b/Bugsnag/Assets/Bugsnag/Runtime/Native/Cocoa/NativeClient.cs index e72201f8..5f8f5031 100644 --- a/Bugsnag/Assets/Bugsnag/Runtime/Native/Cocoa/NativeClient.cs +++ b/Bugsnag/Assets/Bugsnag/Runtime/Native/Cocoa/NativeClient.cs @@ -3,6 +3,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Text; @@ -20,7 +21,7 @@ class NativeClient : INativeClient private static NativeClient _instance; private bool _registeredForSessionCallbacks; - private LoadedImages loadedImages; + private LoadedImages loadedImages = new LoadedImages(); public NativeClient(Configuration configuration) { @@ -488,12 +489,121 @@ public void RegisterForOnSessionCallbacks() NativeCode.bugsnag_registerForSessionCallbacksAfterStart(HandleSessionCallbacks); } - public LoadedImage FindImageAtAddress(UInt64 address) + #nullable enable + private static string? ExtractString(IntPtr pString) { - loadedImages.Refresh(); - return loadedImages.FindImageAtAddress(address); + return (pString == IntPtr.Zero) ? null : Marshal.PtrToStringAnsi(pString); } + private StackTraceLine[] ToStackFrames(System.Exception exception, IntPtr[] nativeAddresses) + { + var unityTrace = new PayloadStackTrace(exception.StackTrace).StackTraceLines; + var length = nativeAddresses.Length < unityTrace.Length ? nativeAddresses.Length : unityTrace.Length; + var stackFrames = new StackTraceLine[length]; + for (int i = 0; i < length; i++) + { + var method = unityTrace[i].Method; + var address = (UInt64)nativeAddresses[i].ToInt64(); + var image = loadedImages.FindImageAtAddress(address); + + var trace = new StackTraceLine(); + trace.FrameAddress = address.ToString(); + trace.Method = method.ToString(); + if (image != null) + { + if (address < image.LoadAddress) + { + // It's a relative address + trace.FrameAddress = (address + image.LoadAddress).ToString(); + } + trace.MachoFile = image.FileName; + trace.MachoLoadAddress = image.LoadAddress.ToString(); + trace.MachoUuid = image.Uuid; + trace.InProject = image.IsMainImage; + } + stackFrames[i] = trace; + } + return stackFrames; + } + +#if ENABLE_IL2CPP && UNITY_2021_3_OR_NEWER + [DllImport("__Internal")] + private static extern IntPtr il2cpp_gchandle_get_target(int gchandle); + + [DllImport("__Internal")] + private static extern void il2cpp_free(IntPtr ptr); + + [DllImport("__Internal")] + private static extern void il2cpp_native_stack_trace(IntPtr exc, out IntPtr addresses, out int numFrames, out IntPtr imageUUID, out IntPtr imageName); +#endif + + public StackTraceLine[] ToStackFrames(System.Exception exception) + { + var notFound = new StackTraceLine[0]; + + if (exception == null) + { + return notFound; + } + +#if ENABLE_IL2CPP && UNITY_2021_3_OR_NEWER + var hException = GCHandle.Alloc(exception); + var pNativeAddresses = IntPtr.Zero; + var pImageUuid = IntPtr.Zero; + var pImageName = IntPtr.Zero; + try + { + if (hException == null) + { + return notFound; + } + + var pException = il2cpp_gchandle_get_target(GCHandle.ToIntPtr(hException).ToInt32()); + if (pException == IntPtr.Zero) + { + return notFound; + } + + var frameCount = 0; + string? mainImageFileName = null; + + il2cpp_native_stack_trace(pException, out pNativeAddresses, out frameCount, out pImageUuid, out pImageName); + if (pNativeAddresses == IntPtr.Zero) + { + return notFound; + } + + mainImageFileName = ExtractString(pImageName); + var nativeAddresses = new IntPtr[frameCount]; + Marshal.Copy(pNativeAddresses, nativeAddresses, 0, frameCount); + + loadedImages.Refresh(mainImageFileName); + return ToStackFrames(exception, nativeAddresses); + } + finally + { + if (pImageUuid != IntPtr.Zero) + { + il2cpp_free(pImageUuid); + } + if (pImageName != IntPtr.Zero) + { + il2cpp_free(pImageName); + } + if (pNativeAddresses != IntPtr.Zero) + { + il2cpp_free(pNativeAddresses); + } + if (hException != null) + { + hException.Free(); + } + } +#else + return notFound; +#endif + } } } + #endif \ No newline at end of file diff --git a/Bugsnag/Assets/Bugsnag/Runtime/Native/Fallback/NativeClient.cs b/Bugsnag/Assets/Bugsnag/Runtime/Native/Fallback/NativeClient.cs index d42f34ad..5b5b3a8e 100644 --- a/Bugsnag/Assets/Bugsnag/Runtime/Native/Fallback/NativeClient.cs +++ b/Bugsnag/Assets/Bugsnag/Runtime/Native/Fallback/NativeClient.cs @@ -171,9 +171,9 @@ public void RegisterForOnSessionCallbacks() // Not Used on this platform } - public LoadedImage FindImageAtAddress(UInt64 address) + public StackTraceLine[] ToStackFrames(System.Exception exception) { - return null; + return new StackTraceLine[0]; } } } diff --git a/Bugsnag/Assets/Bugsnag/Runtime/Native/Windows/NativeClient.cs b/Bugsnag/Assets/Bugsnag/Runtime/Native/Windows/NativeClient.cs index bc977518..facb5a75 100644 --- a/Bugsnag/Assets/Bugsnag/Runtime/Native/Windows/NativeClient.cs +++ b/Bugsnag/Assets/Bugsnag/Runtime/Native/Windows/NativeClient.cs @@ -218,9 +218,9 @@ public void RegisterForOnSessionCallbacks() // Not Used on this platform } - public LoadedImage FindImageAtAddress(UInt64 address) + public StackTraceLine[] ToStackFrames(System.Exception exception) { - return null; + return new StackTraceLine[0]; } } } diff --git a/Bugsnag/Assets/Bugsnag/Runtime/Payload/ErrorBuilder.cs b/Bugsnag/Assets/Bugsnag/Runtime/Payload/ErrorBuilder.cs index db16d05a..7df602d0 100644 --- a/Bugsnag/Assets/Bugsnag/Runtime/Payload/ErrorBuilder.cs +++ b/Bugsnag/Assets/Bugsnag/Runtime/Payload/ErrorBuilder.cs @@ -93,13 +93,19 @@ internal Error FromSystemException(System.Exception exception, string stackTrace return new Error(errorClass, exception.Message, lines); } + internal Error FromSystemException(System.Exception exception, System.Diagnostics.StackFrame[] alternativeStackTrace) { + var frames = NativeClient.ToStackFrames(exception); var errorClass = exception.GetType().Name; - // JVM exceptions in the main thread are handled by unity and require extra formatting - if (errorClass == ANDROID_JAVA_EXCEPTION_CLASS) + if (frames.Length > 0) + { + return new Error(errorClass, exception.Message, frames); + } + else if (errorClass == ANDROID_JAVA_EXCEPTION_CLASS) { + // JVM exceptions in the main thread are handled by unity and require extra formatting var androidErrorData = ProcessAndroidError(exception.Message); var androidErrorClass = androidErrorData[0]; var androidErrorMessage = androidErrorData[1]; diff --git a/Bugsnag/Assets/Bugsnag/Runtime/Payload/StackTraceLine.cs b/Bugsnag/Assets/Bugsnag/Runtime/Payload/StackTraceLine.cs index c40ad6ff..07a6f3a1 100644 --- a/Bugsnag/Assets/Bugsnag/Runtime/Payload/StackTraceLine.cs +++ b/Bugsnag/Assets/Bugsnag/Runtime/Payload/StackTraceLine.cs @@ -140,6 +140,10 @@ internal StackTraceLine(Dictionary data) } } + internal StackTraceLine() + { + } + public string File { get @@ -176,14 +180,66 @@ public string Method } } - public string FrameAddress { get; set; } + public string FrameAddress { + get + { + return this.Get("frameAddress") as string; + } + set + { + this.AddToPayload("frameAddress", value); + } + } + + public string MachoLoadAddress { + get + { + return this.Get("machoLoadAddress") as string; + } + set + { + this.AddToPayload("machoLoadAddress", value); + } + } + + public string MachoFile { + get + { + return this.Get("machoFile") as string; + } + set + { + this.AddToPayload("machoFile", value); + } + } + + public string MachoUuid { + get + { + return this.Get("machoUUID") as string; + } + set + { + this.AddToPayload("machoUUID", value); + } + } + + public bool? InProject + { + get + { + return this.Get("inProject") as bool?; + } + set + { + this.AddToPayload("inProject", value); + } + } + + public bool? IsLr { get; set; } public bool? IsPc { get; set; } - public string MachoFile { get; set; } - public string MachoLoadAddress { get; set; } - public string MachoUuid { get; set; } public string MachoVmAddress { get; set; } public string SymbolAddress { get; set; } - public bool? InProject { get; set; } } } diff --git a/features/csharp/csharp_events.feature b/features/csharp/csharp_events.feature index fb5a8f17..598eeed9 100644 --- a/features/csharp/csharp_events.feature +++ b/features/csharp/csharp_events.feature @@ -30,6 +30,25 @@ Feature: csharp events And expected device metadata is included in the event And expected app metadata is included in the event + @ios_only + @skip_before_unity_2021 + Scenario: Uncaught Exception ios smoke test + When I run the game in the "UncaughtExceptionSmokeTest" state + And I wait to receive an error + Then the error is valid for the error reporting API sent by the Unity notifier + And the exception "errorClass" equals "Exception" + And the exception "message" equals "UncaughtExceptionSmokeTest" + And the event "unhandled" is false + And custom metadata is included in the event + And expected device metadata is included in the event + And expected app metadata is included in the event + And the error payload field "events.0.exceptions.0.stacktrace.0.frameAddress" matches the regex "\d+" + And the error payload field "events.0.exceptions.0.stacktrace.0.method" equals "UncaughtExceptionSmokeTest.Run()" + And the error payload field "events.0.exceptions.0.stacktrace.0.machoFile" matches the regex ".*/UnityFramework.framework/UnityFramework" + And the error payload field "events.0.exceptions.0.stacktrace.0.machoLoadAddress" matches the regex "\d+" + And the error payload field "events.0.exceptions.0.stacktrace.0.machoUUID" matches the regex "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + And the error payload field "events.0.exceptions.0.stacktrace.0.inProject" is true + Scenario: Debug Log Exception smoke test When I run the game in the "DebugLogExceptionSmokeTest" state And I wait to receive an error diff --git a/features/support/env.rb b/features/support/env.rb index f3d75ae4..4a0769e6 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -9,6 +9,15 @@ end end +Before('@skip_before_unity_2021') do |_scenario| + if ENV['UNITY_VERSION'] + unity_version = ENV['UNITY_VERSION'][0..3].to_i + if unity_version < 2021 + skip_this_scenario('Skipping scenario on Unity < 2021') + end + end +end + Before('@skip_webgl') do |_scenario| skip_this_scenario('Skipping scenario') unless Maze.config.browser.nil?