Skip to content

Commit 870d249

Browse files
authored
Merge pull request #2389 from bugsnag/PLAT-15240/apphang-recovery
AppHang cooldown option
2 parents d0cef6a + ed828b7 commit 870d249

9 files changed

Lines changed: 302 additions & 116 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
* Added `NativeOutOfMemoryPlugin` as a new way to report `OutOfMemoryError`s that uses pre-allocated memory in the NDK module instead of allocating an `Event` object. When used `OutOfMemoryError`s will be more reliably reported, but will not be passed to `OnErrorCallback`s (`OnSendCallback` works as expected).
88
[#2384](https://github.com/bugsnag/bugsnag-android/pull/2384)
9+
* Added `appHangCooldownMillis` to the AppHangPlugin to control the number of AppHang errors produced when the app is performance constrained
10+
[]()
911

1012
## 6.24.0 (2026-02-11)
1113

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,6 @@ endif
8585

8686
.PHONY: check
8787
check:
88-
@./gradlew lint detekt ktlintCheck checkstyle
88+
@./gradlew lint detekt ktlintCheck checkstyle apiCheck
8989
@./scripts/run-cpp-check.sh
9090
@./scripts/run-clang-format-ci-check.sh

bugsnag-plugin-android-apphang/api/bugsnag-plugin-android-apphang.api

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
public final class com/bugsnag/android/AppHangConfiguration {
22
public fun <init> ()V
3-
public fun <init> (JLandroid/os/Looper;Ljava/lang/Long;J)V
4-
public synthetic fun <init> (JLandroid/os/Looper;Ljava/lang/Long;JILkotlin/jvm/internal/DefaultConstructorMarker;)V
3+
public fun <init> (JLandroid/os/Looper;Ljava/lang/Long;JJ)V
4+
public synthetic fun <init> (JLandroid/os/Looper;Ljava/lang/Long;JJILkotlin/jvm/internal/DefaultConstructorMarker;)V
5+
public final fun getAppHangCooldownMillis ()J
56
public final fun getAppHangThresholdMillis ()J
67
public final fun getStackSamplingIntervalMillis ()J
78
public final fun getStackSamplingThresholdMillis ()Ljava/lang/Long;
89
public final fun getWatchedLooper ()Landroid/os/Looper;
10+
public final fun setAppHangCooldownMillis (J)V
911
public final fun setAppHangThresholdMillis (J)V
1012
public final fun setStackSamplingIntervalMillis (J)V
1113
public final fun setStackSamplingThresholdMillis (Ljava/lang/Long;)V

bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/BugsnagAppHangPluginTest.kt

Lines changed: 0 additions & 104 deletions
This file was deleted.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.bugsnag.android
2+
3+
import android.os.Handler
4+
import android.os.HandlerThread
5+
import com.bugsnag.android.internal.LooperMonitorThread
6+
import org.junit.After
7+
import org.junit.Assert.assertEquals
8+
import org.junit.Before
9+
import org.junit.Test
10+
import java.util.concurrent.CountDownLatch
11+
import java.lang.Thread as JThread
12+
13+
private const val APP_HANG_THRESHOLD = 200L
14+
private const val COOLDOWN_TIME = 800L
15+
16+
class LooperMonitorThreadTest {
17+
private lateinit var handlerThread: HandlerThread
18+
private lateinit var monitorThread: LooperMonitorThread
19+
private lateinit var handler: Handler
20+
21+
private var appHangCount = 0
22+
23+
@Before
24+
fun setup() {
25+
appHangCount = 0
26+
27+
handlerThread = HandlerThread("Test Thread")
28+
handlerThread.start()
29+
handler = Handler(handlerThread.looper)
30+
31+
monitorThread = LooperMonitorThread(
32+
watchedLooper = handlerThread.looper,
33+
appHangThresholdMillis = APP_HANG_THRESHOLD,
34+
appHangCooldownMillis = COOLDOWN_TIME,
35+
samplingThresholdMillis = 0,
36+
samplingRateMillis = 0,
37+
onAppHangDetected = { _, _ -> appHangCount++ }
38+
)
39+
40+
monitorThread.startMonitoring()
41+
}
42+
43+
@After
44+
fun shutdown() {
45+
monitorThread.stopMonitoring()
46+
handlerThread.quit()
47+
}
48+
49+
@Test
50+
fun testIdleHandlerThread() {
51+
JThread.sleep(APP_HANG_THRESHOLD * 5)
52+
assertEquals("no AppHangs expected", 0, appHangCount)
53+
}
54+
55+
@Test
56+
fun testBelowThresholdEvents() {
57+
val countDownLatch = CountDownLatch(10)
58+
val task = object : Runnable {
59+
override fun run() {
60+
JThread.sleep(APP_HANG_THRESHOLD / 2)
61+
countDownLatch.countDown()
62+
63+
if (countDownLatch.count > 0) {
64+
handler.postDelayed(this, 1L)
65+
}
66+
}
67+
}
68+
handler.postDelayed(task, 1)
69+
70+
countDownLatch.await()
71+
assertEquals("no AppHangs expected", 0, appHangCount)
72+
}
73+
74+
@Test
75+
fun appHang() {
76+
val countDownLatch = CountDownLatch(1)
77+
handler.postDelayed({
78+
// wait long enough for 2+ AppHang triggers to happen
79+
JThread.sleep(APP_HANG_THRESHOLD * 3)
80+
countDownLatch.countDown()
81+
}, 1)
82+
83+
countDownLatch.await()
84+
85+
assertEquals("exactly 1 AppHang expected", 1, appHangCount)
86+
}
87+
88+
@Test
89+
fun appHangRecoverHang() {
90+
val countDownLatch = CountDownLatch(3)
91+
92+
handler.postDelayed({
93+
JThread.sleep(APP_HANG_THRESHOLD * 2)
94+
countDownLatch.countDown()
95+
96+
handler.postDelayed({
97+
// This AppHang is within the cooldown period, so should be suppressed
98+
// Starts 100ms after first ends, detected at 300ms total (well within 800ms cooldown)
99+
JThread.sleep(APP_HANG_THRESHOLD * 2)
100+
countDownLatch.countDown()
101+
102+
handler.postDelayed({
103+
// This AppHang is after the cooldown period, so should be reported
104+
// Starts 800ms after second ends, giving enough time for cooldown to expire
105+
JThread.sleep(APP_HANG_THRESHOLD * 2)
106+
countDownLatch.countDown()
107+
}, COOLDOWN_TIME + 200L)
108+
}, 100L)
109+
}, 1)
110+
111+
countDownLatch.await()
112+
113+
// First and third AppHangs should be reported, second suppressed by cooldown
114+
assertEquals("exactly 2 AppHangs expected", 2, appHangCount)
115+
}
116+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.bugsnag.android
2+
3+
import android.os.Handler
4+
import android.os.HandlerThread
5+
import com.bugsnag.android.internal.LooperMonitorThread
6+
import org.junit.After
7+
import org.junit.Assert.assertEquals
8+
import org.junit.Before
9+
import org.junit.Test
10+
import java.util.concurrent.CountDownLatch
11+
import java.lang.Thread as JThread
12+
13+
private const val APP_HANG_THRESHOLD = 200L
14+
15+
/**
16+
* Tests for LooperMonitorThread without cooldown period configured.
17+
* All detected AppHangs should be reported.
18+
*/
19+
class SequentialAppHangsTest {
20+
private lateinit var handlerThread: HandlerThread
21+
private lateinit var monitorThread: LooperMonitorThread
22+
private lateinit var handler: Handler
23+
24+
private var appHangCount = 0
25+
26+
@Before
27+
fun setup() {
28+
appHangCount = 0
29+
30+
handlerThread = HandlerThread("Test Thread")
31+
handlerThread.start()
32+
handler = Handler(handlerThread.looper)
33+
34+
monitorThread = LooperMonitorThread(
35+
watchedLooper = handlerThread.looper,
36+
appHangThresholdMillis = APP_HANG_THRESHOLD,
37+
appHangCooldownMillis = 0L, // No cooldown
38+
samplingThresholdMillis = 0,
39+
samplingRateMillis = 0,
40+
onAppHangDetected = { _, _ -> appHangCount++ }
41+
)
42+
43+
monitorThread.startMonitoring()
44+
}
45+
46+
@After
47+
fun shutdown() {
48+
monitorThread.stopMonitoring()
49+
handlerThread.quit()
50+
}
51+
52+
@Test
53+
fun testIdleHandlerThread() {
54+
JThread.sleep(APP_HANG_THRESHOLD * 5)
55+
assertEquals("no AppHangs expected", 0, appHangCount)
56+
}
57+
58+
@Test
59+
fun testBelowThresholdEvents() {
60+
val countDownLatch = CountDownLatch(10)
61+
val task = object : Runnable {
62+
override fun run() {
63+
JThread.sleep(APP_HANG_THRESHOLD / 2)
64+
countDownLatch.countDown()
65+
66+
if (countDownLatch.count > 0) {
67+
handler.postDelayed(this, 1L)
68+
}
69+
}
70+
}
71+
handler.postDelayed(task, 1)
72+
73+
countDownLatch.await()
74+
assertEquals("no AppHangs expected", 0, appHangCount)
75+
}
76+
77+
@Test
78+
fun appHang() {
79+
val countDownLatch = CountDownLatch(1)
80+
handler.postDelayed({
81+
// wait long enough for 2+ AppHang triggers to happen
82+
JThread.sleep(APP_HANG_THRESHOLD * 3)
83+
countDownLatch.countDown()
84+
}, 1)
85+
86+
countDownLatch.await()
87+
88+
assertEquals("exactly 1 AppHang expected", 1, appHangCount)
89+
}
90+
91+
@Test
92+
fun appHangRecoverHang() {
93+
val countDownLatch = CountDownLatch(2)
94+
95+
handler.postDelayed({
96+
JThread.sleep(APP_HANG_THRESHOLD * 2)
97+
countDownLatch.countDown()
98+
99+
handler.postDelayed({
100+
// Without cooldown, this AppHang should also be reported
101+
JThread.sleep(APP_HANG_THRESHOLD * 2)
102+
countDownLatch.countDown()
103+
}, 100L) // Small delay to ensure recovery between hangs
104+
}, 1)
105+
106+
countDownLatch.await()
107+
108+
// Without cooldown, both AppHangs should be reported
109+
assertEquals("exactly 2 AppHangs expected", 2, appHangCount)
110+
}
111+
112+
@Test
113+
fun multipleSequentialHangs() {
114+
val countDownLatch = CountDownLatch(3)
115+
116+
handler.postDelayed({
117+
JThread.sleep(APP_HANG_THRESHOLD * 2)
118+
countDownLatch.countDown()
119+
120+
handler.postDelayed({
121+
JThread.sleep(APP_HANG_THRESHOLD * 2)
122+
countDownLatch.countDown()
123+
124+
handler.postDelayed({
125+
JThread.sleep(APP_HANG_THRESHOLD * 2)
126+
countDownLatch.countDown()
127+
}, 100L)
128+
}, 100L)
129+
}, 1)
130+
131+
countDownLatch.await()
132+
133+
// Without cooldown, all 3 AppHangs should be reported
134+
assertEquals("exactly 3 AppHangs expected", 3, appHangCount)
135+
}
136+
}

0 commit comments

Comments
 (0)