Skip to content

Commit 3ddf010

Browse files
authored
fix: Android app crashing on hot-restart in debug mode (#3358)
* Fix hot-restart crash * Remove stale file * Formatting * Formatting * Dedupe * Update CHANGELOG
1 parent cdf371b commit 3ddf010

File tree

3 files changed

+94
-1
lines changed

3 files changed

+94
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixes
66

7+
- Android app crashing on hot-restart in debug mode ([#3358](https://github.com/getsentry/sentry-dart/pull/3358))
78
- Dont use `Companion` in JNI calls and properly release JNI refs ([#3354](https://github.com/getsentry/sentry-dart/pull/3354))
89
- This potentially fixes segfault crashes related to JNI
910

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package io.sentry.flutter
2+
3+
import android.util.Log
4+
import java.util.concurrent.atomic.AtomicInteger
5+
6+
/**
7+
* Wraps [ReplayRecorderCallbacks] and guards all callbacks behind a generation check (to ignore stale callbacks from previous isolates).
8+
* Without this check the app would crash after a hot-restart in debug mode.
9+
*/
10+
internal class SafeReplayRecorderCallbacks(
11+
private val delegate: ReplayRecorderCallbacks,
12+
) : ReplayRecorderCallbacks {
13+
companion object {
14+
private val generationCounter = AtomicInteger(0)
15+
16+
fun bumpGeneration() {
17+
generationCounter.incrementAndGet()
18+
}
19+
20+
fun currentGeneration(): Int = generationCounter.get()
21+
}
22+
23+
private val generationSnapshot: Int = currentGeneration()
24+
25+
private inline fun guard(block: () -> Unit) {
26+
if (generationSnapshot != currentGeneration()) return
27+
try {
28+
block()
29+
} catch (t: Throwable) {
30+
Log.w("Sentry", "Replay recorder callback failed", t)
31+
}
32+
}
33+
34+
override fun replayStarted(
35+
replayId: String,
36+
replayIsBuffering: Boolean,
37+
) = guard {
38+
delegate.replayStarted(replayId, replayIsBuffering)
39+
}
40+
41+
override fun replayResumed() =
42+
guard {
43+
delegate.replayResumed()
44+
}
45+
46+
override fun replayPaused() =
47+
guard {
48+
delegate.replayPaused()
49+
}
50+
51+
override fun replayStopped() =
52+
guard {
53+
delegate.replayStopped()
54+
}
55+
56+
override fun replayReset() =
57+
guard {
58+
delegate.replayReset()
59+
}
60+
61+
override fun replayConfigChanged(
62+
width: Int,
63+
height: Int,
64+
frameRate: Int,
65+
) = guard {
66+
delegate.replayConfigChanged(width, height, frameRate)
67+
}
68+
}

packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class SentryFlutterPlugin :
7171
return
7272
}
7373

74+
tearDownReplayIntegration()
7475
channel.setMethodCallHandler(null)
7576
applicationContext = null
7677
}
@@ -111,6 +112,25 @@ class SentryFlutterPlugin :
111112

112113
private const val NATIVE_CRASH_WAIT_TIME = 500L
113114

115+
/**
116+
* Tears down the current ReplayIntegration to avoid invoking callbacks from a stale
117+
* Flutter isolate after hot restart.
118+
*
119+
* - Bumps the replay callback generation so any pending posts from the previous
120+
* isolate no-op.
121+
* - Closes the existing ReplayIntegration and clears its reference.
122+
*/
123+
fun tearDownReplayIntegration() {
124+
SafeReplayRecorderCallbacks.bumpGeneration()
125+
try {
126+
replay?.close()
127+
} catch (e: Exception) {
128+
Log.w("Sentry", "Failed to close existing ReplayIntegration", e)
129+
} finally {
130+
replay = null
131+
}
132+
}
133+
114134
@Suppress("unused") // Used by native/jni bindings
115135
@JvmStatic
116136
fun privateSentryGetReplayIntegration(): ReplayIntegration? = replay
@@ -120,6 +140,8 @@ class SentryFlutterPlugin :
120140
options: SentryAndroidOptions,
121141
replayCallbacks: ReplayRecorderCallbacks?,
122142
) {
143+
tearDownReplayIntegration()
144+
123145
// Replace the default ReplayIntegration with a Flutter-specific recorder.
124146
options.integrations.removeAll { it is ReplayIntegration }
125147
val replayOptions = options.sessionReplay
@@ -130,12 +152,14 @@ class SentryFlutterPlugin :
130152
return
131153
}
132154

155+
val safeCallbacks = SafeReplayRecorderCallbacks(replayCallbacks)
156+
133157
replay =
134158
ReplayIntegration(
135159
ctx.applicationContext,
136160
dateProvider = CurrentDateProvider.getInstance(),
137161
recorderProvider = {
138-
SentryFlutterReplayRecorder(replayCallbacks, replay!!)
162+
SentryFlutterReplayRecorder(safeCallbacks, replay!!)
139163
},
140164
replayCacheProvider = null,
141165
)

0 commit comments

Comments
 (0)