diff --git a/Runtime/Native/OSX/NativeClient.cs b/Runtime/Native/OSX/NativeClient.cs index f27c1bae..ffedb09e 100644 --- a/Runtime/Native/OSX/NativeClient.cs +++ b/Runtime/Native/OSX/NativeClient.cs @@ -75,6 +75,10 @@ public NativeClient(BacktraceConfiguration configuration, BacktraceBreadcrumbs b HandleNativeCrashes(clientAttributes, attachments); INITIALIZED = true; } + else + { + PendingCrashReportQuarantine.SetCaptureActive(false); + } if (_configuration.HandleANR) { HandleAnr(); @@ -93,6 +97,7 @@ private void HandleNativeCrashes(IDictionary attributes, IEnumer if (string.IsNullOrEmpty(databasePath) || !Directory.Exists(databasePath)) { Debug.LogWarning("Backtrace native integration status: database path doesn't exist"); + PendingCrashReportQuarantine.SetCaptureActive(false); return; } @@ -105,7 +110,11 @@ private void HandleNativeCrashes(IDictionary attributes, IEnumer var attributeKeys = attributes.Keys.ToArray(); var attributeValues = attributes.Values.ToArray(); + // hand the previous session's quarantined report back to PLCrashReporter right before it starts, so the report is submitted and purged as usual + // see PendingCrashReportQuarantine for the Unity built-in crash reporter conflict + PendingCrashReportQuarantine.RestoreLiveReport(PendingCrashReportQuarantine.GetApplicationReportDirectory()); Start(plcrashreporterUrl.ToString(), attributeKeys, attributeValues, attributeValues.Length, _configuration.OomReports, attachments.ToArray(), attachments.Count(), _configuration.ClientSideUnwinding); + PendingCrashReportQuarantine.SetCaptureActive(true); CaptureNativeCrashes = true; } @@ -270,6 +279,7 @@ public override void Disable() if (CaptureNativeCrashes) { CaptureNativeCrashes = false; + PendingCrashReportQuarantine.SetCaptureActive(false); DisableNativeIntegration(); } base.Disable(); diff --git a/Runtime/Native/OSX/PendingCrashReportQuarantine.cs b/Runtime/Native/OSX/PendingCrashReportQuarantine.cs new file mode 100644 index 00000000..6c7162e0 --- /dev/null +++ b/Runtime/Native/OSX/PendingCrashReportQuarantine.cs @@ -0,0 +1,183 @@ +using System; +using System.IO; +using UnityEngine; + +namespace Backtrace.Unity.Runtime.Native.OSX +{ + /// + /// Guards Backtrace's pending macOS crash reports against Unity's built-in crash reporter. + /// + internal static class PendingCrashReportQuarantine + { + /// + /// PLCrashReporter's fixed pending report file name. + /// + internal const string LiveReportFileName = "live_report.plcrash"; + + /// + /// Name the pending report is parked under while hidden from Unity. + /// + internal const string QuarantinedReportFileName = LiveReportFileName + ".backtrace-quarantine"; + + /// + /// PlayerPrefs flag persisted while the Backtrace native crash handler is active, so the next launch knows a pending report in the shared directory belongs to this SDK. + /// + internal const string CaptureActivePlayerPrefsKey = "backtrace-osx-native-capture"; + + /// + /// PLCrashReporter's shared cache directory name. + /// + private const string PlCrashReporterCacheDirectory = "com.plausiblelabs.crashreporter.data"; + + /// + /// PLCrashReporter's per-application report directory under the given caches root. + /// + internal static string GetDefaultReportDirectory(string cachesRoot, string bundleIdentifier) + { + return Path.Combine(Path.Combine(cachesRoot, PlCrashReporterCacheDirectory), bundleIdentifier); + } + + /// + /// Default PLCrashReporter report directory of the running application, or null when it cannot be determined. Never throws. + /// + internal static string GetApplicationReportDirectory() + { + try + { + var bundleIdentifier = Application.identifier; + if (string.IsNullOrEmpty(bundleIdentifier)) + { + return null; + } + var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal); + if (string.IsNullOrEmpty(home)) + { + return null; + } + // PLCrashReporter's default basePath is NSCachesDirectory + var cachesRoot = Path.Combine(Path.Combine(home, "Library"), "Caches"); + return GetDefaultReportDirectory(cachesRoot, bundleIdentifier); + } + catch (Exception) + { + return null; + } + } + + /// + /// Moves a pending report aside so Unity's crash reporter cannot find it. Never throws. + /// + /// true when a report was quarantined + internal static bool QuarantineLiveReport(string reportDirectory) + { + try + { + if (string.IsNullOrEmpty(reportDirectory)) + { + return false; + } + var liveReport = Path.Combine(reportDirectory, LiveReportFileName); + if (!File.Exists(liveReport)) + { + return false; + } + var quarantinedReport = Path.Combine(reportDirectory, QuarantinedReportFileName); + if (File.Exists(quarantinedReport)) + { + // a previously quarantined report was never handed back, keep the newer crash, matching PLCrashReporter's single-pending-report semantics + File.Delete(quarantinedReport); + } + File.Move(liveReport, quarantinedReport); + return true; + } + catch (Exception) + { + return false; + } + } + + /// + /// Puts a quarantined report back so PLCrashReporter can submit and purge it. Call immediately before the native crash handler starts. Never throws. + /// + /// true when a report was restored + internal static bool RestoreLiveReport(string reportDirectory) + { + try + { + if (string.IsNullOrEmpty(reportDirectory)) + { + return false; + } + var quarantinedReport = Path.Combine(reportDirectory, QuarantinedReportFileName); + if (!File.Exists(quarantinedReport)) + { + return false; + } + var liveReport = Path.Combine(reportDirectory, LiveReportFileName); + if (File.Exists(liveReport)) + { + // a fresh report already occupies the live slot, keep the quarantined one parked for the next launch instead of overwriting either of them + return false; + } + File.Move(quarantinedReport, liveReport); + return true; + } + catch (Exception) + { + return false; + } + } + + /// + /// Persists whether Backtrace native crash capture is active, so the next launch only quarantines reports this SDK is responsible for. + /// Saved immediately because a crashed session never reaches Unity's regular PlayerPrefs flush. Never throws. + /// + internal static void SetCaptureActive(bool active) + { + try + { + var value = active ? 1 : 0; + if (PlayerPrefs.GetInt(CaptureActivePlayerPrefsKey, -1) == value) + { + return; + } + PlayerPrefs.SetInt(CaptureActivePlayerPrefsKey, value); + PlayerPrefs.Save(); + } + catch (Exception) + { + } + } + + /// + /// True when the previous session had Backtrace native crash capture active. + /// + internal static bool IsCaptureActive() + { + try + { + return PlayerPrefs.GetInt(CaptureActivePlayerPrefsKey, 0) == 1; + } + catch (Exception) + { + return false; + } + } + +#if UNITY_STANDALONE_OSX && !UNITY_EDITOR +#if UNITY_2019_2_OR_NEWER + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] +#else + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] +#endif + private static void QuarantineOnStartup() + { + if (!IsCaptureActive()) + { + return; + } + QuarantineLiveReport(GetApplicationReportDirectory()); + } +#endif + } +} diff --git a/Runtime/Native/OSX/PendingCrashReportQuarantine.cs.meta b/Runtime/Native/OSX/PendingCrashReportQuarantine.cs.meta new file mode 100644 index 00000000..c16db935 --- /dev/null +++ b/Runtime/Native/OSX/PendingCrashReportQuarantine.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7901dd6f65fa4b35b038f60c2943f979 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Native/PendingCrashReportQuarantineTests.cs b/Tests/Runtime/Native/PendingCrashReportQuarantineTests.cs new file mode 100644 index 00000000..52d780bb --- /dev/null +++ b/Tests/Runtime/Native/PendingCrashReportQuarantineTests.cs @@ -0,0 +1,155 @@ +using System; +using System.IO; +using Backtrace.Unity.Runtime.Native.OSX; +using NUnit.Framework; +using UnityEngine; + +namespace Backtrace.Unity.Tests.Runtime +{ + public class PendingCrashReportQuarantineTests + { + private string _reportDirectory; + + [SetUp] + public void Setup() + { + _reportDirectory = Path.Combine(Path.GetTempPath(), "backtrace-quarantine-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_reportDirectory); + } + + [TearDown] + public void Cleanup() + { + if (Directory.Exists(_reportDirectory)) + { + Directory.Delete(_reportDirectory, true); + } + } + + private string LiveReportPath + { + get { return Path.Combine(_reportDirectory, PendingCrashReportQuarantine.LiveReportFileName); } + } + + private string QuarantinedReportPath + { + get { return Path.Combine(_reportDirectory, PendingCrashReportQuarantine.QuarantinedReportFileName); } + } + + [Test] + public void Quarantine_MovesPendingReportOutOfUnitysSight() + { + File.WriteAllText(LiveReportPath, "pending-report"); + + Assert.IsTrue(PendingCrashReportQuarantine.QuarantineLiveReport(_reportDirectory)); + + Assert.IsFalse(File.Exists(LiveReportPath)); + Assert.AreEqual("pending-report", File.ReadAllText(QuarantinedReportPath)); + } + + [Test] + public void Quarantine_WithoutPendingReport_DoesNothing() + { + Assert.IsFalse(PendingCrashReportQuarantine.QuarantineLiveReport(_reportDirectory)); + + Assert.IsFalse(File.Exists(LiveReportPath)); + Assert.IsFalse(File.Exists(QuarantinedReportPath)); + } + + [Test] + public void Quarantine_ReplacesStaleQuarantinedReportWithNewerCrash() + { + File.WriteAllText(QuarantinedReportPath, "stale-report"); + File.WriteAllText(LiveReportPath, "newer-report"); + + Assert.IsTrue(PendingCrashReportQuarantine.QuarantineLiveReport(_reportDirectory)); + + Assert.IsFalse(File.Exists(LiveReportPath)); + Assert.AreEqual("newer-report", File.ReadAllText(QuarantinedReportPath)); + } + + [Test] + public void Restore_HandsQuarantinedReportBackToPlCrashReporter() + { + File.WriteAllText(QuarantinedReportPath, "pending-report"); + + Assert.IsTrue(PendingCrashReportQuarantine.RestoreLiveReport(_reportDirectory)); + + Assert.IsFalse(File.Exists(QuarantinedReportPath)); + Assert.AreEqual("pending-report", File.ReadAllText(LiveReportPath)); + } + + [Test] + public void Restore_WithoutQuarantinedReport_DoesNothing() + { + Assert.IsFalse(PendingCrashReportQuarantine.RestoreLiveReport(_reportDirectory)); + + Assert.IsFalse(File.Exists(LiveReportPath)); + Assert.IsFalse(File.Exists(QuarantinedReportPath)); + } + + [Test] + public void Restore_KeepsBothReportsWhenAFreshLiveReportExists() + { + File.WriteAllText(QuarantinedReportPath, "quarantined-report"); + File.WriteAllText(LiveReportPath, "fresh-report"); + + Assert.IsFalse(PendingCrashReportQuarantine.RestoreLiveReport(_reportDirectory)); + + Assert.AreEqual("fresh-report", File.ReadAllText(LiveReportPath)); + Assert.AreEqual("quarantined-report", File.ReadAllText(QuarantinedReportPath)); + } + + [Test] + public void QuarantineAndRestore_NeverThrowForInvalidDirectories() + { + var missingDirectory = Path.Combine(_reportDirectory, "does-not-exist"); + + Assert.DoesNotThrow(() => PendingCrashReportQuarantine.QuarantineLiveReport(null)); + Assert.DoesNotThrow(() => PendingCrashReportQuarantine.RestoreLiveReport(null)); + Assert.IsFalse(PendingCrashReportQuarantine.QuarantineLiveReport(missingDirectory)); + Assert.IsFalse(PendingCrashReportQuarantine.RestoreLiveReport(missingDirectory)); + } + + [Test] + public void DefaultReportDirectory_FollowsPlCrashReporterLayout() + { + var expected = Path.Combine(Path.Combine("caches", "com.plausiblelabs.crashreporter.data"), "com.example.app"); + + Assert.AreEqual(expected, PendingCrashReportQuarantine.GetDefaultReportDirectory("caches", "com.example.app")); + } + + [Test] + public void ApplicationReportDirectory_NeverThrows() + { + Assert.DoesNotThrow(() => PendingCrashReportQuarantine.GetApplicationReportDirectory()); + } + + [Test] + public void CaptureActiveFlag_RoundTripsThroughPlayerPrefs() + { + var hadKey = PlayerPrefs.HasKey(PendingCrashReportQuarantine.CaptureActivePlayerPrefsKey); + var previousValue = hadKey ? PlayerPrefs.GetInt(PendingCrashReportQuarantine.CaptureActivePlayerPrefsKey) : 0; + try + { + PendingCrashReportQuarantine.SetCaptureActive(true); + Assert.IsTrue(PendingCrashReportQuarantine.IsCaptureActive()); + + PendingCrashReportQuarantine.SetCaptureActive(false); + Assert.IsFalse(PendingCrashReportQuarantine.IsCaptureActive()); + } + finally + { + if (hadKey) + { + PlayerPrefs.SetInt(PendingCrashReportQuarantine.CaptureActivePlayerPrefsKey, previousValue); + } + else + { + PlayerPrefs.DeleteKey(PendingCrashReportQuarantine.CaptureActivePlayerPrefsKey); + } + PlayerPrefs.Save(); + } + } + } +} diff --git a/Tests/Runtime/Native/PendingCrashReportQuarantineTests.cs.meta b/Tests/Runtime/Native/PendingCrashReportQuarantineTests.cs.meta new file mode 100644 index 00000000..b5b936ac --- /dev/null +++ b/Tests/Runtime/Native/PendingCrashReportQuarantineTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 71ad8c16cbb24f4c8ad2f8d2a1ac5bdd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: