Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
* Added support for Turbo Module native stacktraces in ``bugsnag-plugin-react-native`
[#2367](https://github.com/bugsnag/bugsnag-android/pull/2367)

### Bug fixes

* Replaced the heartbeat lock with park/unpark in the [bugsnag-plugin-android-apphang](bugsnag-plugin-android-apphang) so that the main/monitor threads are not interdependant
[#2363](https://github.com/bugsnag/bugsnag-android/pull/2363)

## 6.21.0 (2026-01-05)

### Enhancements
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import android.os.Looper
import android.os.SystemClock
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.locks.ReentrantLock
import java.util.concurrent.locks.LockSupport

internal class LooperMonitorThread(
watchedLooper: Looper,
Expand All @@ -14,15 +14,13 @@ internal class LooperMonitorThread(
) : Thread("Bugsnag AppHang Monitor: ${watchedLooper.thread.name}") {
private val handler: Handler = Handler(watchedLooper)

@Volatile
private var lastHeartbeatTimestamp = 0L

private val isRunning = AtomicBoolean(false)

private var isAppHangDetected = false

private val heartbeatLock = ReentrantLock(false)
private val heartbeatCondition = heartbeatLock.newCondition()

private val heartbeat: Runnable = Heartbeat()

private fun calculateTimeToAppHang(now: Long): Long =
Expand All @@ -36,22 +34,17 @@ internal class LooperMonitorThread(

fun stopMonitoring() {
if (isRunning.compareAndSet(true, false)) {
interrupt()
handler.removeCallbacks(heartbeat)
LockSupport.unpark(this)
}
}

internal fun resetHeartbeatTimer() {
heartbeatLock.lock()
try {
heartbeatCondition.signalAll()
} finally {
heartbeatLock.unlock()
}
LockSupport.unpark(this)
}

private fun reportAppHang(timeSinceLastHeartbeat: Long) {
if (isAppHangDetected) {
// avoid reporting duplicate AppHangs
return
}

Expand All @@ -63,37 +56,34 @@ internal class LooperMonitorThread(
handler.post(heartbeat)

while (isRunning.get()) {
heartbeatLock.lock()
try {
val waitThreshold =
if (lastHeartbeatTimestamp <= 0L) appHangThresholdMillis
else calculateTimeToAppHang(SystemClock.elapsedRealtime())
heartbeatCondition.await(waitThreshold, TimeUnit.MILLISECONDS)

val timeSinceLastHeartbeat = SystemClock.elapsedRealtime() - lastHeartbeatTimestamp

if (timeSinceLastHeartbeat >= appHangThresholdMillis) {
reportAppHang(timeSinceLastHeartbeat)
}
} catch (_: InterruptedException) {
// continue loop and check isRunning
} finally {
heartbeatLock.unlock()
val waitThreshold =
if (lastHeartbeatTimestamp <= 0L) appHangThresholdMillis
else calculateTimeToAppHang(SystemClock.uptimeMillis())

val waitThresholdNanos = TimeUnit.MILLISECONDS.toNanos(waitThreshold)
LockSupport.parkNanos(waitThresholdNanos)

if (!isRunning.get()) break

val timeSinceLastHeartbeat = SystemClock.uptimeMillis() - lastHeartbeatTimestamp

if (timeSinceLastHeartbeat >= appHangThresholdMillis) {
reportAppHang(timeSinceLastHeartbeat)
}

if (!handler.post(heartbeat)) {
// handler.post returning false means the Looper has likely quit
isRunning.set(false)
}
}
}

private inner class Heartbeat : Runnable {
override fun run() {
lastHeartbeatTimestamp = SystemClock.elapsedRealtime()
// mark the hang as "recovered" and start the detection again
lastHeartbeatTimestamp = SystemClock.uptimeMillis()
isAppHangDetected = false
resetHeartbeatTimer()

// only post the Heartbeat messages if the monitor is still running
if (isRunning.get()) {
handler.post(this)
}
resetHeartbeatTimer()
}

override fun toString(): String {
Expand Down
Loading