Skip to content

Commit daf3ab6

Browse files
committed
fix(database,android): fail a transaction whose handler never replies
A transaction whose Dart handler did not reply within 30 seconds, or was released because the plugin detached from its engine, resolved with `committed: false` and no error, exactly like a handler that returned `Transaction.abort()`. A debugger pause longer than the timeout silently aborted valid transactions. `runTransaction` now throws a `FirebaseException` instead: code `deadline-exceeded` on timeout (as cloud_firestore does), or `cancelled` when the engine detached first. The failure is recorded on the handler and reported from onComplete, because the RTDB SDK turns an exception thrown from doTransaction into an uncoded "user code" error. Also: - reset the detached state when the plugin is attached again, so a re-attached plugin calls Dart again instead of aborting every transaction; - use a reply that arrives just after the timeout, and only log when the result is really missing; - correct the comment on the timeout, which is reached by debugger pauses, blocked main threads and replies lost during hot restart or detach.
1 parent 326aa8c commit daf3ab6

2 files changed

Lines changed: 189 additions & 103 deletions

File tree

‎packages/firebase_database/firebase_database/android/src/main/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.kt‎

Lines changed: 99 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ internal constructor(
5252
private const val METHOD_CHANNEL_NAME = "plugins.flutter.io/firebase_database"
5353
private val databaseInstanceCache = HashMap<String, FirebaseDatabase>()
5454

55-
// The Dart handler is synchronous, so this only fires when no reply is coming. It matches the
56-
// default transaction timeout of cloud_firestore.
55+
// How long a transaction waits for its Dart handler, which is synchronous. It is reached when
56+
// the isolate is paused in a debugger, the main thread is blocked, or the reply is lost, as
57+
// during a hot restart or once the engine is detached. It matches the default transaction
58+
// timeout of cloud_firestore.
5759
private val TRANSACTION_HANDLER_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(30)
5860
}
5961

@@ -82,7 +84,9 @@ internal constructor(
8284
}
8385
}
8486

85-
private fun initPluginInstance(messenger: BinaryMessenger) {
87+
internal fun initPluginInstance(messenger: BinaryMessenger) {
88+
// The plugin can be attached again after a detach.
89+
isDetached = false
8690
FlutterFirebasePluginRegistry.registerPlugin(METHOD_CHANNEL_NAME, this)
8791
this.messenger = messenger
8892

@@ -457,6 +461,10 @@ internal constructor(
457461
override fun onDetachedFromEngine(
458462
@NonNull binding: FlutterPluginBinding,
459463
) {
464+
detachFromEngine()
465+
}
466+
467+
internal fun detachFromEngine() {
460468
isDetached = true
461469
pendingTransactionHandlers.forEach { it.countDown() }
462470
methodChannel.setMethodCallHandler(null)
@@ -807,51 +815,7 @@ internal constructor(
807815
transactionRequests[request.transactionKey] = request
808816

809817
// Start the transaction - simplified approach like iOS
810-
reference.runTransaction(
811-
object : com.google.firebase.database.Transaction.Handler {
812-
override fun doTransaction(
813-
mutableData: com.google.firebase.database.MutableData
814-
): com.google.firebase.database.Transaction.Result {
815-
val result =
816-
callTransactionHandler(request.transactionKey, mutableData.value)
817-
?: return com.google.firebase.database.Transaction.abort()
818-
819-
if (result.aborted || result.exception) {
820-
return com.google.firebase.database.Transaction.abort()
821-
}
822-
823-
mutableData.value = result.value
824-
return com.google.firebase.database.Transaction.success(mutableData)
825-
}
826-
827-
override fun onComplete(
828-
error: com.google.firebase.database.DatabaseError?,
829-
committed: Boolean,
830-
currentData: com.google.firebase.database.DataSnapshot?
831-
) {
832-
// Store the transaction result for later retrieval
833-
val result =
834-
mapOf(
835-
"committed" to committed,
836-
"snapshot" to
837-
mapOf(
838-
"value" to currentData?.value,
839-
"key" to currentData?.key,
840-
"exists" to currentData?.exists()))
841-
transactionResults[request.transactionKey] = result
842-
843-
// Complete the transaction - simplified like iOS
844-
if (error != null) {
845-
val ex = FlutterFirebaseDatabaseException.fromDatabaseError(error)
846-
callback(
847-
KotlinResult.failure(
848-
FlutterError("firebase_database", ex.message, ex.additionalData)))
849-
} else {
850-
callback(KotlinResult.success(Unit))
851-
}
852-
}
853-
},
854-
request.applyLocally)
818+
reference.runTransaction(transactionHandler(request, callback), request.applyLocally)
855819
} catch (e: Exception) {
856820
// Convert generic exceptions to FlutterFirebaseDatabaseException for proper error handling
857821
val flutterException =
@@ -1059,6 +1023,65 @@ internal constructor(
10591023
return database
10601024
}
10611025

1026+
/** Bridges the RTDB transaction of [request] to its Dart handler, and reports to [callback]. */
1027+
internal fun transactionHandler(
1028+
request: TransactionRequest,
1029+
callback: (KotlinResult<Unit>) -> Unit
1030+
): com.google.firebase.database.Transaction.Handler =
1031+
object : com.google.firebase.database.Transaction.Handler {
1032+
// Why the Dart handler gave no result. Reported from onComplete rather than thrown, because
1033+
// the SDK turns an exception thrown here into an uncoded "user code" error.
1034+
@Volatile private var handlerFailure: FlutterFirebaseDatabaseException? = null
1035+
1036+
override fun doTransaction(
1037+
mutableData: com.google.firebase.database.MutableData
1038+
): com.google.firebase.database.Transaction.Result {
1039+
val result =
1040+
try {
1041+
callTransactionHandler(request.transactionKey, mutableData.value)
1042+
} catch (e: FlutterFirebaseDatabaseException) {
1043+
handlerFailure = e
1044+
return com.google.firebase.database.Transaction.abort()
1045+
}
1046+
1047+
if (result.aborted || result.exception) {
1048+
return com.google.firebase.database.Transaction.abort()
1049+
}
1050+
1051+
mutableData.value = result.value
1052+
return com.google.firebase.database.Transaction.success(mutableData)
1053+
}
1054+
1055+
override fun onComplete(
1056+
error: com.google.firebase.database.DatabaseError?,
1057+
committed: Boolean,
1058+
currentData: com.google.firebase.database.DataSnapshot?
1059+
) {
1060+
// Store the transaction result for later retrieval
1061+
val result =
1062+
mapOf(
1063+
"committed" to committed,
1064+
"snapshot" to
1065+
mapOf(
1066+
"value" to currentData?.value,
1067+
"key" to currentData?.key,
1068+
"exists" to currentData?.exists()))
1069+
transactionResults[request.transactionKey] = result
1070+
1071+
// Complete the transaction - simplified like iOS
1072+
val ex =
1073+
handlerFailure
1074+
?: error?.let { FlutterFirebaseDatabaseException.fromDatabaseError(it) }
1075+
if (ex != null) {
1076+
callback(
1077+
KotlinResult.failure(
1078+
FlutterError("firebase_database", ex.message, ex.additionalData)))
1079+
} else {
1080+
callback(KotlinResult.success(Unit))
1081+
}
1082+
}
1083+
}
1084+
10621085
// Dart transaction handlers that RTDB run-loop threads are waiting on.
10631086
private val pendingTransactionHandlers: MutableSet<CountDownLatch> = ConcurrentHashMap.newKeySet()
10641087
@Volatile private var isDetached = false
@@ -1070,34 +1093,42 @@ internal constructor(
10701093
* until it returns. Nothing replies when the engine is destroyed or its messenger detached while
10711094
* the handler is pending, so the wait times out, and [onDetachedFromEngine] releases it at once.
10721095
*
1073-
* @return the handler's result, or `null` to abort the transaction.
1096+
* @throws FlutterFirebaseDatabaseException `deadline-exceeded` when Dart does not reply in time,
1097+
* or `cancelled` when the plugin is detached from its engine first.
10741098
*/
1075-
internal fun callTransactionHandler(
1076-
transactionKey: Long,
1077-
value: Any?
1078-
): TransactionHandlerResult? {
1099+
internal fun callTransactionHandler(transactionKey: Long, value: Any?): TransactionHandlerResult {
10791100
val latch = CountDownLatch(1)
10801101
pendingTransactionHandlers.add(latch)
10811102
try {
10821103
// Checked after registering the latch, so a concurrent detach either sees it or is seen here.
1083-
if (isDetached) {
1084-
return null
1085-
}
1086-
val transactionResult = AtomicReference<TransactionHandlerResult?>()
1087-
// FlutterJNI must be called on the main thread.
1088-
mainThreadExecutor.execute {
1089-
FirebaseDatabaseFlutterApi(messenger).callTransactionHandler(transactionKey, value) {
1090-
transactionResult.set(
1091-
it.getOrElse {
1092-
TransactionHandlerResult(value = null, aborted = true, exception = true)
1093-
})
1094-
latch.countDown()
1104+
if (!isDetached) {
1105+
val transactionResult = AtomicReference<TransactionHandlerResult?>()
1106+
// FlutterJNI must be called on the main thread.
1107+
mainThreadExecutor.execute {
1108+
FirebaseDatabaseFlutterApi(messenger).callTransactionHandler(transactionKey, value) {
1109+
transactionResult.set(
1110+
it.getOrElse {
1111+
TransactionHandlerResult(value = null, aborted = true, exception = true)
1112+
})
1113+
latch.countDown()
1114+
}
1115+
}
1116+
latch.await(transactionHandlerTimeoutMillis, TimeUnit.MILLISECONDS)
1117+
// Read after the wait, so a reply that arrives just after the timeout is still used.
1118+
transactionResult.get()?.let {
1119+
return it
10951120
}
10961121
}
1097-
if (!latch.await(transactionHandlerTimeoutMillis, TimeUnit.MILLISECONDS)) {
1098-
Log.w(TAG, "The Dart transaction handler did not reply, aborting the transaction.")
1122+
if (isDetached) {
1123+
throw FlutterFirebaseDatabaseException(
1124+
"cancelled",
1125+
"The transaction was cancelled because the Flutter engine was detached before its " +
1126+
"handler replied.")
10991127
}
1100-
return transactionResult.get()
1128+
Log.w(TAG, "The Dart transaction handler did not reply in time, failing the transaction.")
1129+
throw FlutterFirebaseDatabaseException(
1130+
"deadline-exceeded",
1131+
"The transaction handler did not reply within $transactionHandlerTimeoutMillis ms.")
11011132
} finally {
11021133
pendingTransactionHandlers.remove(latch)
11031134
}

‎packages/firebase_database/firebase_database/android/src/test/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePluginTest.kt‎

Lines changed: 90 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44

55
package io.flutter.plugins.firebase.database
66

7-
import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding
7+
import com.google.firebase.database.InternalHelpers
8+
import com.google.firebase.database.MutableData
9+
import com.google.firebase.database.Transaction
10+
import com.google.firebase.database.snapshot.NodeUtilities
811
import io.flutter.plugin.common.BinaryMessenger
912
import io.flutter.plugin.common.BinaryMessenger.BinaryMessageHandler
1013
import io.flutter.plugin.common.BinaryMessenger.BinaryReply
@@ -14,9 +17,10 @@ import java.util.concurrent.Executors
1417
import java.util.concurrent.Future
1518
import java.util.concurrent.LinkedBlockingQueue
1619
import java.util.concurrent.TimeUnit
20+
import kotlin.Result as KotlinResult
1721
import org.junit.After
1822
import org.junit.Assert.assertEquals
19-
import org.junit.Assert.assertNull
23+
import org.junit.Assert.assertFalse
2024
import org.junit.Assert.assertTrue
2125
import org.junit.Test
2226
import org.junit.runner.RunWith
@@ -37,15 +41,23 @@ class FirebaseDatabasePluginTest {
3741
override fun setMessageHandler(channel: String, handler: BinaryMessageHandler?) {}
3842
}
3943

44+
/** A transaction as the RTDB SDK drives it, with what the plugin reports to Dart. */
45+
private inner class TransactionRun(plugin: FirebaseDatabasePlugin) {
46+
var reported: KotlinResult<Unit>? = null
47+
val handler =
48+
plugin.transactionHandler(
49+
TransactionRequest(path = "counter", transactionKey = 1, applyLocally = true)) {
50+
reported = it
51+
}
52+
val mutableData: MutableData =
53+
InternalHelpers.createMutableData(NodeUtilities.NodeFromJSON("current"))
54+
55+
/** Runs the handler on a separate thread, as the RTDB SDK does from its run loop. */
56+
fun doTransactionOnRunLoop(): Future<Transaction.Result> =
57+
rtdbRunLoop.submit<Transaction.Result> { handler.doTransaction(mutableData) }
58+
}
59+
4060
private val messenger = FakeDartMessenger()
41-
// The plugin only uses the messenger. The constructor is called reflectively because Kotlin
42-
// rejects null for its other, @NonNull, parameters.
43-
private val binding =
44-
FlutterPluginBinding::class
45-
.java
46-
.constructors
47-
.single()
48-
.newInstance(null, null, messenger, null, null, null, null) as FlutterPluginBinding
4961
private val rtdbRunLoop = Executors.newSingleThreadExecutor()
5062

5163
@After
@@ -56,54 +68,97 @@ class FirebaseDatabasePluginTest {
5668
private fun attachedPlugin(timeoutMillis: Long): FirebaseDatabasePlugin {
5769
// Runs the main-thread work inline, so the fake Dart side receives calls synchronously.
5870
val plugin = FirebaseDatabasePlugin(Executor { it.run() }, timeoutMillis)
59-
plugin.onAttachedToEngine(binding)
71+
plugin.initPluginInstance(messenger)
6072
return plugin
6173
}
6274

63-
/** Calls the handler from a separate thread, as the RTDB SDK does from its run loop. */
64-
private fun callTransactionHandlerOnRunLoop(
65-
plugin: FirebaseDatabasePlugin
66-
): Future<TransactionHandlerResult?> =
67-
rtdbRunLoop.submit<TransactionHandlerResult?> { plugin.callTransactionHandler(1, "current") }
75+
private fun replyFromDart(result: TransactionHandlerResult) {
76+
messenger.pendingReplies
77+
.poll(5, TimeUnit.SECONDS)!!
78+
.reply(FirebaseDatabaseFlutterApi.codec.encodeMessage(listOf(result))?.apply { rewind() })
79+
}
6880

69-
@Test
70-
fun callTransactionHandler_returnsTheResultFromDart() {
71-
val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(1))
81+
/** Asserts that Dart gets a `FirebaseException` with [code]. */
82+
private fun assertReportedFailure(code: String, reported: KotlinResult<Unit>?) {
83+
val error = reported!!.exceptionOrNull() as FlutterError
84+
// The Dart side reads the code of a `FirebaseException` from the details.
85+
assertEquals("firebase_database", error.code)
86+
assertEquals(code, (error.details as Map<*, *>)["code"])
87+
}
7288

73-
val pending = callTransactionHandlerOnRunLoop(plugin)
74-
val reply = messenger.pendingReplies.poll(5, TimeUnit.SECONDS)!!
75-
val dartResult = TransactionHandlerResult(value = "updated", aborted = false, exception = false)
76-
reply.reply(
77-
FirebaseDatabaseFlutterApi.codec.encodeMessage(listOf(dartResult))?.apply { rewind() })
89+
@Test(timeout = 10_000)
90+
fun transaction_commitsTheValueFromDart() {
91+
val run = TransactionRun(attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(1)))
92+
93+
val pending = run.doTransactionOnRunLoop()
94+
replyFromDart(TransactionHandlerResult(value = "updated", aborted = false, exception = false))
7895

79-
assertEquals(dartResult, pending.get(5, TimeUnit.SECONDS))
96+
assertTrue(pending.get().isSuccess)
97+
assertEquals("updated", run.mutableData.value)
98+
run.handler.onComplete(null, true, null)
99+
assertTrue(run.reported!!.isSuccess)
80100
}
81101

82102
@Test(timeout = 10_000)
83-
fun callTransactionHandler_abortsWhenDartNeverReplies() {
84-
val plugin = attachedPlugin(timeoutMillis = 100)
103+
fun transaction_abortedByDartCompletesWithoutAnError() {
104+
val run = TransactionRun(attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(1)))
105+
106+
val pending = run.doTransactionOnRunLoop()
107+
replyFromDart(TransactionHandlerResult(value = null, aborted = true, exception = false))
85108

86-
assertNull(callTransactionHandlerOnRunLoop(plugin).get())
109+
assertFalse(pending.get().isSuccess)
110+
run.handler.onComplete(null, false, null)
111+
// Dart reads `committed: false`.
112+
assertTrue(run.reported!!.isSuccess)
87113
}
88114

89115
@Test(timeout = 10_000)
90-
fun onDetachedFromEngine_releasesAPendingTransactionHandler() {
116+
fun transaction_failsWithDeadlineExceededWhenDartNeverReplies() {
117+
val run = TransactionRun(attachedPlugin(timeoutMillis = 100))
118+
119+
assertFalse(run.doTransactionOnRunLoop().get().isSuccess)
120+
run.handler.onComplete(null, false, null)
121+
122+
assertReportedFailure("deadline-exceeded", run.reported)
123+
}
124+
125+
@Test(timeout = 10_000)
126+
fun detachFromEngine_failsAPendingTransaction() {
91127
val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(10))
128+
val run = TransactionRun(plugin)
92129

93-
val pending = callTransactionHandlerOnRunLoop(plugin)
130+
val pending = run.doTransactionOnRunLoop()
94131
// Dart received the call, and the engine goes away before it replies.
95132
messenger.pendingReplies.take()
96-
plugin.onDetachedFromEngine(binding)
133+
plugin.detachFromEngine()
97134

98-
assertNull(pending.get())
135+
assertFalse(pending.get().isSuccess)
136+
run.handler.onComplete(null, false, null)
137+
assertReportedFailure("cancelled", run.reported)
99138
}
100139

101140
@Test(timeout = 10_000)
102-
fun callTransactionHandler_abortsWithoutCallingDartAfterDetach() {
141+
fun transaction_failsWithoutCallingDartAfterDetach() {
103142
val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(10))
104-
plugin.onDetachedFromEngine(binding)
143+
plugin.detachFromEngine()
144+
val run = TransactionRun(plugin)
105145

106-
assertNull(callTransactionHandlerOnRunLoop(plugin).get())
146+
assertFalse(run.doTransactionOnRunLoop().get().isSuccess)
107147
assertTrue(messenger.pendingReplies.isEmpty())
148+
run.handler.onComplete(null, false, null)
149+
assertReportedFailure("cancelled", run.reported)
150+
}
151+
152+
@Test(timeout = 10_000)
153+
fun initPluginInstance_callsDartAgainAfterADetach() {
154+
val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(1))
155+
plugin.detachFromEngine()
156+
plugin.initPluginInstance(messenger)
157+
val run = TransactionRun(plugin)
158+
159+
val pending = run.doTransactionOnRunLoop()
160+
replyFromDart(TransactionHandlerResult(value = "updated", aborted = false, exception = false))
161+
162+
assertTrue(pending.get().isSuccess)
108163
}
109164
}

0 commit comments

Comments
 (0)