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
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e #v4.31.8
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 #v4.31.10
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
Expand All @@ -83,6 +83,6 @@ jobs:
./gradlew --no-daemon assemble

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e #v4.31.8
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 #v4.31.10
with:
category: "/language:${{matrix.language}}"
2 changes: 1 addition & 1 deletion .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
sarif_file: results.sarif

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 6.22.0 (2026-01-19)

### Enhancements

* 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 @@ -7,7 +7,7 @@ import java.io.IOException
*/
class Notifier @JvmOverloads constructor(
var name: String = "Android Bugsnag Notifier",
var version: String = "6.21.0",
var version: String = "6.22.0",
var url: String = "https://bugsnag.com"
) : JsonStream.Streamable {

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
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
class ErrorDeserializer implements MapDeserializer<Error> {

private final StackframeDeserializer stackframeDeserializer;
private final NativeStackDeserializer nativeStackDeserializer;
private final Logger logger;

ErrorDeserializer(StackframeDeserializer stackframeDeserializer, Logger logger) {
ErrorDeserializer(StackframeDeserializer stackframeDeserializer,
NativeStackDeserializer nativeStackDeserializer,
Logger logger) {
this.stackframeDeserializer = stackframeDeserializer;
this.nativeStackDeserializer = nativeStackDeserializer;
this.logger = logger;
}

Expand All @@ -31,6 +35,14 @@ public Error deserialize(Map<String, Object> map) {
new Stacktrace(frames),
ErrorType.valueOf(type.toUpperCase(Locale.US))
);
return new Error(impl, logger);

Error error = new Error(impl, logger);

if (map.containsKey("nativeStack")) {
List<Stackframe> nativeStack = nativeStackDeserializer.deserialize(map);
error.getStacktrace().addAll(0, nativeStack);
}

return error;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ internal class EventDeserializer(
private val appDeserializer = AppDeserializer()
private val deviceDeserializer = DeviceDeserializer()
private val stackframeDeserializer = StackframeDeserializer()
private val errorDeserializer = ErrorDeserializer(stackframeDeserializer, client.getLogger())
private val nativeStackDeserializer = NativeStackDeserializer(projectPackages, client.config)
private val errorDeserializer = ErrorDeserializer(
stackframeDeserializer,
nativeStackDeserializer,
client.getLogger()
)
private val threadDeserializer = ThreadDeserializer(stackframeDeserializer, client.getLogger())
private val breadcrumbDeserializer = BreadcrumbDeserializer(client.getLogger())

Expand Down Expand Up @@ -67,8 +72,6 @@ internal class EventDeserializer(
if (map.containsKey("nativeStack") && event.errors.isNotEmpty()) {
runCatching {
val jsError = event.errors.first()
val nativeStackDeserializer =
NativeStackDeserializer(projectPackages, client.config)
val nativeStack = nativeStackDeserializer.deserialize(map)
jsError.stacktrace.addAll(0, nativeStack)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@
class MapUtils {

@SuppressWarnings("unchecked")
static <T> T getOrNull(Map<String, Object> map, String key) {
Object id = map.get(key);
return id != null ? (T) id : null;
static <T> T getOrNull(Map<String, Object> map, String... keys) {
for (String key : keys) {
Object value = map.get(key);
if (value != null) {
return (T) value;
}
}
return null;
}

@SuppressWarnings("unchecked")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ private Stackframe deserializeStackframe(Map<String, Object> map,
methodName = "";
}

String clz = MapUtils.getOrNull(map, "class");
String clz = MapUtils.getOrNull(map, "className", "class");
String method = clz + "." + methodName;

// RN <0.63.2 doesn't add class, gracefully fallback by only reporting
Expand All @@ -54,9 +54,11 @@ private Stackframe deserializeStackframe(Map<String, Object> map,
clz = "";
method = methodName;
}

String file = MapUtils.getOrNull(map, "fileName", "file");
Stackframe stackframe = new Stackframe(
method,
MapUtils.<String>getOrNull(map, "file"),
file,
MapUtils.<Integer>getOrNull(map, "lineNumber"),
Stacktrace.Companion.inProject(clz, projectPackages)
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,103 @@
package com.bugsnag.android

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.HashMap

class ErrorDeserializerTest {

private val map = HashMap<String, Any>()

/**
* Generates a map for verifying the serializer
*/
@Before
fun setup() {
private fun createErrorMap() = HashMap<String, Any>().apply {
val frame = HashMap<String, Any>()
frame["method"] = "foo()"
frame["file"] = "Bar.kt"
frame["lineNumber"] = 29
frame["inProject"] = true
map["stacktrace"] = listOf(frame)
map["errorClass"] = "BrowserException"
map["errorMessage"] = "whoops!"
map["type"] = "reactnativejs"
this["stacktrace"] = listOf(frame)
this["errorClass"] = "BrowserException"
this["errorMessage"] = "whoops!"
this["type"] = "reactnativejs"
}

private fun createNativeStackFrames(): List<Map<String, Any>> = listOf(
mapOf(
"methodName" to "nativeMethod1",
"lineNumber" to 100,
"fileName" to "Native.java",
"className" to "com.reactnativetest.Native"
),
mapOf(
"methodName" to "nativeMethod2",
"lineNumber" to 200,
"fileName" to "NativeHelper.kt",
"className" to "com.example.NativeHelper"
)
)

@Test
fun deserialize() {
val error = ErrorDeserializer(StackframeDeserializer(), object : Logger {}).deserialize(map)
fun deserializeWithoutNativeStack() {
val map = createErrorMap()
val packages = listOf("com.reactnativetest")
val cfg = TestData.generateConfig()
val nativeStackDeserializer = NativeStackDeserializer(packages, cfg)
val errorDeserializer = ErrorDeserializer(
StackframeDeserializer(),
nativeStackDeserializer,
object : Logger {}
)
val error = errorDeserializer.deserialize(map)

assertEquals("BrowserException", error.errorClass)
assertEquals("whoops!", error.errorMessage)
assertEquals(ErrorType.REACTNATIVEJS, error.type)
assertEquals(1, error.stacktrace.size)

val jsFrame = error.stacktrace[0]
assertEquals("foo()", jsFrame.method)
assertEquals("Bar.kt", jsFrame.file)
assertEquals(29, jsFrame.lineNumber)
assertTrue(jsFrame.inProject as Boolean)
}

@Test
fun deserializeWithNativeStack() {
val map = createErrorMap()
map["nativeStack"] = createNativeStackFrames()

val packages = listOf("com.reactnativetest")
val cfg = TestData.generateConfig()
val nativeStackDeserializer = NativeStackDeserializer(packages, cfg)
val errorDeserializer = ErrorDeserializer(StackframeDeserializer(), nativeStackDeserializer, object : Logger {})
val error = errorDeserializer.deserialize(map)

assertEquals("BrowserException", error.errorClass)
assertEquals("whoops!", error.errorMessage)
assertEquals(ErrorType.REACTNATIVEJS, error.type)

// Should have 3 frames total: 2 native frames + 1 JS frame
assertEquals(3, error.stacktrace.size)

// Native frames should be at the start (indices 0 and 1)
val firstNativeFrame = error.stacktrace[0]
assertEquals("com.reactnativetest.Native.nativeMethod1", firstNativeFrame.method)
assertEquals("Native.java", firstNativeFrame.file)
assertEquals(100, firstNativeFrame.lineNumber)
assertTrue(firstNativeFrame.inProject!!)
assertEquals(ErrorType.ANDROID, firstNativeFrame.type)

val secondNativeFrame = error.stacktrace[1]
assertEquals("com.example.NativeHelper.nativeMethod2", secondNativeFrame.method)
assertEquals("NativeHelper.kt", secondNativeFrame.file)
assertEquals(200, secondNativeFrame.lineNumber)
assertNull(secondNativeFrame.inProject)
assertEquals(ErrorType.ANDROID, secondNativeFrame.type)

val frame = error.stacktrace[0]
assertEquals("foo()", frame.method)
assertEquals("Bar.kt", frame.file)
assertEquals(29, frame.lineNumber)
assertTrue(frame.inProject as Boolean)
// Original JS frame should now be at index 2
val jsFrame = error.stacktrace[2]
assertEquals("foo()", jsFrame.method)
assertEquals("Bar.kt", jsFrame.file)
assertEquals(29, jsFrame.lineNumber)
assertTrue(jsFrame.inProject as Boolean)
}
}
Loading
Loading