From bb5f3013bbb5f1a69c31c19baa6284bca7b5b2a2 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 20 Nov 2025 13:35:54 +0100 Subject: [PATCH 1/6] Fix hot-restart crash --- .../flutter/SafeReplayRecorderCallbacks.kt | 66 +++++++++++++++++++ .../flutter/SafeSentryReplayRecorder.kt | 46 +++++++++++++ .../io/sentry/flutter/SentryFlutterPlugin.kt | 25 ++++++- 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt create mode 100644 packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeSentryReplayRecorder.kt diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt new file mode 100644 index 0000000000..acd2f42310 --- /dev/null +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt @@ -0,0 +1,66 @@ +package io.sentry.flutter + +import android.util.Log +import java.util.concurrent.atomic.AtomicInteger + +/** + * Wraps [ReplayRecorderCallbacks] and guards all callbacks behind a generation check (to ignore stale callbacks from previous isolates). + * Without this check the app would crash after a hot-restart in debug mode. + */ +internal class SafeReplayRecorderCallbacks( + private val delegate: ReplayRecorderCallbacks, +) : ReplayRecorderCallbacks { + companion object { + private val generationCounter = AtomicInteger(0) + + fun bumpGeneration() { + generationCounter.incrementAndGet() + } + + fun currentGeneration(): Int = generationCounter.get() + } + + private val generationSnapshot: Int = currentGeneration() + + private inline fun guard(block: () -> Unit) { + if (generationSnapshot != currentGeneration()) return + try { + block() + } catch (t: Throwable) { + Log.w("Sentry", "Replay recorder callback failed", t) + } + } + + override fun replayStarted( + replayId: String, + replayIsBuffering: Boolean, + ) = guard { + delegate.replayStarted(replayId, replayIsBuffering) + } + + override fun replayResumed() = guard { + delegate.replayResumed() + } + + override fun replayPaused() = guard { + delegate.replayPaused() + } + + override fun replayStopped() = guard { + delegate.replayStopped() + } + + override fun replayReset() = guard { + delegate.replayReset() + } + + override fun replayConfigChanged( + width: Int, + height: Int, + frameRate: Int, + ) = guard { + delegate.replayConfigChanged(width, height, frameRate) + } +} + + diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeSentryReplayRecorder.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeSentryReplayRecorder.kt new file mode 100644 index 0000000000..dd89247834 --- /dev/null +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeSentryReplayRecorder.kt @@ -0,0 +1,46 @@ +//package io.sentry.flutter +// +//import io.sentry.android.replay.Recorder +//import io.sentry.android.replay.ReplayIntegration +//import io.sentry.android.replay.ScreenshotRecorderConfig +// +//internal class SafeSentryReplayRecorder( +// replayCallbacks: ReplayRecorderCallbacks, +// integration: ReplayIntegration, +//) : Recorder { +// private val delegate = +// SentryFlutterReplayRecorder( +// SafeReplayRecorderCallbacks(replayCallbacks), +// integration, +// ) +// +// override fun start() { +// delegate.start() +// } +// +// override fun resume() { +// delegate.resume() +// } +// +// override fun onConfigurationChanged(config: ScreenshotRecorderConfig) { +// delegate.onConfigurationChanged(config) +// } +// +// override fun reset() { +// delegate.reset() +// } +// +// override fun pause() { +// delegate.pause() +// } +// +// override fun stop() { +// delegate.stop() +// } +// +// override fun close() { +// delegate.close() +// } +//} +// +// diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index dd6896e9e7..28d64b5449 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -71,6 +71,18 @@ class SentryFlutterPlugin : return } + // Bump generation so any pending replay tasks from a previous isolate no-op + SafeReplayRecorderCallbacks.bumpGeneration() + + // Ensure Replay integration is torn down to avoid calls into a stale Dart isolate + try { + replay?.close() + } catch (e: Exception) { + Log.w("Sentry", "Failed to close ReplayIntegration on detach", e) + } finally { + replay = null + } + channel.setMethodCallHandler(null) applicationContext = null } @@ -121,6 +133,15 @@ class SentryFlutterPlugin : replayCallbacks: ReplayRecorderCallbacks?, ) { // Replace the default ReplayIntegration with a Flutter-specific recorder. + // First, bump generation and close any existing instance to avoid stale callbacks after hot restart. + SafeReplayRecorderCallbacks.bumpGeneration() + try { + replay?.close() + } catch (e: Exception) { + Log.w("Sentry", "Failed to close existing ReplayIntegration", e) + } finally { + replay = null + } options.integrations.removeAll { it is ReplayIntegration } val replayOptions = options.sessionReplay if ((replayOptions.isSessionReplayEnabled || replayOptions.isSessionReplayForErrorsEnabled) && replayCallbacks != null) { @@ -130,12 +151,14 @@ class SentryFlutterPlugin : return } + val safeCallbacks = SafeReplayRecorderCallbacks(replayCallbacks) + replay = ReplayIntegration( ctx.applicationContext, dateProvider = CurrentDateProvider.getInstance(), recorderProvider = { - SentryFlutterReplayRecorder(replayCallbacks, replay!!) + SentryFlutterReplayRecorder(safeCallbacks, replay!!) }, replayCacheProvider = null, ) From 1858cb46568947059b2e13c5f29f37c369a772c9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 20 Nov 2025 13:36:21 +0100 Subject: [PATCH 2/6] Remove stale file --- .../flutter/SafeSentryReplayRecorder.kt | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeSentryReplayRecorder.kt diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeSentryReplayRecorder.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeSentryReplayRecorder.kt deleted file mode 100644 index dd89247834..0000000000 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeSentryReplayRecorder.kt +++ /dev/null @@ -1,46 +0,0 @@ -//package io.sentry.flutter -// -//import io.sentry.android.replay.Recorder -//import io.sentry.android.replay.ReplayIntegration -//import io.sentry.android.replay.ScreenshotRecorderConfig -// -//internal class SafeSentryReplayRecorder( -// replayCallbacks: ReplayRecorderCallbacks, -// integration: ReplayIntegration, -//) : Recorder { -// private val delegate = -// SentryFlutterReplayRecorder( -// SafeReplayRecorderCallbacks(replayCallbacks), -// integration, -// ) -// -// override fun start() { -// delegate.start() -// } -// -// override fun resume() { -// delegate.resume() -// } -// -// override fun onConfigurationChanged(config: ScreenshotRecorderConfig) { -// delegate.onConfigurationChanged(config) -// } -// -// override fun reset() { -// delegate.reset() -// } -// -// override fun pause() { -// delegate.pause() -// } -// -// override fun stop() { -// delegate.stop() -// } -// -// override fun close() { -// delegate.close() -// } -//} -// -// From 5c9c1da8efd5dbc02101ccfaf2ed1ea6a104eddb Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 20 Nov 2025 13:42:35 +0100 Subject: [PATCH 3/6] Formatting --- .../kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt index acd2f42310..4525fab29c 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt @@ -62,5 +62,3 @@ internal class SafeReplayRecorderCallbacks( delegate.replayConfigChanged(width, height, frameRate) } } - - From 1f19d3e65cb096c1366883193bf5eb075378b87c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 20 Nov 2025 13:52:06 +0100 Subject: [PATCH 4/6] Formatting --- .../flutter/SafeReplayRecorderCallbacks.kt | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt index 4525fab29c..38efedbb47 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt @@ -38,21 +38,25 @@ internal class SafeReplayRecorderCallbacks( delegate.replayStarted(replayId, replayIsBuffering) } - override fun replayResumed() = guard { - delegate.replayResumed() - } + override fun replayResumed() = + guard { + delegate.replayResumed() + } - override fun replayPaused() = guard { - delegate.replayPaused() - } + override fun replayPaused() = + guard { + delegate.replayPaused() + } - override fun replayStopped() = guard { - delegate.replayStopped() - } + override fun replayStopped() = + guard { + delegate.replayStopped() + } - override fun replayReset() = guard { - delegate.replayReset() - } + override fun replayReset() = + guard { + delegate.replayReset() + } override fun replayConfigChanged( width: Int, From df23ec75b301cd9e6b5ed91cf36404a3eed789b2 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 20 Nov 2025 14:04:30 +0100 Subject: [PATCH 5/6] Dedupe --- .../io/sentry/flutter/SentryFlutterPlugin.kt | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index 28d64b5449..713c8cb840 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -71,18 +71,7 @@ class SentryFlutterPlugin : return } - // Bump generation so any pending replay tasks from a previous isolate no-op - SafeReplayRecorderCallbacks.bumpGeneration() - - // Ensure Replay integration is torn down to avoid calls into a stale Dart isolate - try { - replay?.close() - } catch (e: Exception) { - Log.w("Sentry", "Failed to close ReplayIntegration on detach", e) - } finally { - replay = null - } - + tearDownReplayIntegration() channel.setMethodCallHandler(null) applicationContext = null } @@ -123,6 +112,25 @@ class SentryFlutterPlugin : private const val NATIVE_CRASH_WAIT_TIME = 500L + /** + * Tears down the current ReplayIntegration to avoid invoking callbacks from a stale + * Flutter isolate after hot restart. + * + * - Bumps the replay callback generation so any pending posts from the previous + * isolate no-op. + * - Closes the existing ReplayIntegration and clears its reference. + */ + fun tearDownReplayIntegration() { + SafeReplayRecorderCallbacks.bumpGeneration() + try { + replay?.close() + } catch (e: Exception) { + Log.w("Sentry", "Failed to close existing ReplayIntegration", e) + } finally { + replay = null + } + } + @Suppress("unused") // Used by native/jni bindings @JvmStatic fun privateSentryGetReplayIntegration(): ReplayIntegration? = replay @@ -132,16 +140,9 @@ class SentryFlutterPlugin : options: SentryAndroidOptions, replayCallbacks: ReplayRecorderCallbacks?, ) { + tearDownReplayIntegration() + // Replace the default ReplayIntegration with a Flutter-specific recorder. - // First, bump generation and close any existing instance to avoid stale callbacks after hot restart. - SafeReplayRecorderCallbacks.bumpGeneration() - try { - replay?.close() - } catch (e: Exception) { - Log.w("Sentry", "Failed to close existing ReplayIntegration", e) - } finally { - replay = null - } options.integrations.removeAll { it is ReplayIntegration } val replayOptions = options.sessionReplay if ((replayOptions.isSessionReplayEnabled || replayOptions.isSessionReplayForErrorsEnabled) && replayCallbacks != null) { From 331bcc295e0acc7d3e6d110fe34d7f2e7a54f8e6 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 20 Nov 2025 14:05:52 +0100 Subject: [PATCH 6/6] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e8355405f..5d16edaf5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Android app crashing on hot-restart in debug mode ([#3358](https://github.com/getsentry/sentry-dart/pull/3358)) - Dont use `Companion` in JNI calls and properly release JNI refs ([#3354](https://github.com/getsentry/sentry-dart/pull/3354)) - This potentially fixes segfault crashes related to JNI