From 757dcb7c0835628c27ab12b913ba7d05c2bc0885 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 13:21:43 +0200 Subject: [PATCH 1/2] fix(database,android): stop a pending transaction handler from blocking the database forever doTransaction blocked the RTDB run-loop thread on an unbounded CountDownLatch.await() until Dart replied. When the engine is destroyed, or its messenger detached, while the handler is pending, nothing replies: FlutterJNI drops messages once detached. The run-loop thread then stays blocked and every later operation on that database hangs, including in other engines of the process. Wait at most 30 seconds, as cloud_firestore does for its transactions, then abort the transaction. onDetachedFromEngine releases pending waits at once, and later calls abort without calling Dart. --- .github/workflows/android_unit_tests.yaml | 2 +- .../firebase_database/android/build.gradle | 6 + .../database/FirebaseDatabasePlugin.kt | 94 +++++++++++---- .../database/FirebaseDatabasePluginTest.kt | 109 ++++++++++++++++++ 4 files changed, 187 insertions(+), 24 deletions(-) create mode 100644 packages/firebase_database/firebase_database/android/src/test/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePluginTest.kt diff --git a/.github/workflows/android_unit_tests.yaml b/.github/workflows/android_unit_tests.yaml index 2ec8d17726a1..08e46d234e8c 100644 --- a/.github/workflows/android_unit_tests.yaml +++ b/.github/workflows/android_unit_tests.yaml @@ -43,7 +43,7 @@ jobs: # Tasks are listed per package rather than using the root testDebugUnitTest # task, which would also build the Dart sources of the aggregate test app. # Add a task here when a package gains an android/src/test directory. - run: cd tests/android && ./gradlew :firebase_crashlytics:testDebugUnitTest :cloud_functions:testDebugUnitTest :firebase_messaging:testDebugUnitTest + run: cd tests/android && ./gradlew :firebase_crashlytics:testDebugUnitTest :cloud_functions:testDebugUnitTest :firebase_messaging:testDebugUnitTest :firebase_database:testDebugUnitTest - name: 'Upload test reports' if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a diff --git a/packages/firebase_database/firebase_database/android/build.gradle b/packages/firebase_database/firebase_database/android/build.gradle index 84bd5d56cafa..5423a81cf516 100755 --- a/packages/firebase_database/firebase_database/android/build.gradle +++ b/packages/firebase_database/firebase_database/android/build.gradle @@ -65,6 +65,7 @@ android { srcDirs = ['src/main/kotlin'] } } + test.java.srcDirs += "src/test/kotlin" } buildFeatures { @@ -75,11 +76,16 @@ android { disable 'InvalidPackage' } + testOptions { + unitTests.returnDefaultValues = true + } + dependencies { api firebaseCoreProject implementation platform("com.google.firebase:firebase-bom:${getRootProjectExtOrCoreProperty("FirebaseSDKVersion", firebaseCoreProject)}") implementation 'com.google.firebase:firebase-database' implementation 'androidx.annotation:annotation:1.7.0' + testImplementation 'junit:junit:4.13.2' } } diff --git a/packages/firebase_database/firebase_database/android/src/main/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.kt b/packages/firebase_database/firebase_database/android/src/main/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.kt index ee6a1cf8a381..9d7264b303a1 100644 --- a/packages/firebase_database/firebase_database/android/src/main/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.kt +++ b/packages/firebase_database/firebase_database/android/src/main/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.kt @@ -4,6 +4,9 @@ package io.flutter.plugins.firebase.database +import android.os.Handler +import android.os.Looper +import android.util.Log import androidx.annotation.NonNull import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.TaskCompletionSource @@ -25,14 +28,33 @@ import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugins.firebase.core.FlutterFirebasePlugin import io.flutter.plugins.firebase.core.FlutterFirebasePluginRegistry import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference import kotlin.Result as KotlinResult -class FirebaseDatabasePlugin : FlutterFirebasePlugin, FlutterPlugin, FirebaseDatabaseHostApi { +class FirebaseDatabasePlugin +internal constructor( + private val mainThreadExecutor: Executor, + private val transactionHandlerTimeoutMillis: Long, +) : FlutterFirebasePlugin, FlutterPlugin, FirebaseDatabaseHostApi { + constructor() : + this( + Executor { command -> Handler(Looper.getMainLooper()).post(command) }, + TRANSACTION_HANDLER_TIMEOUT_MILLIS) + companion object { + private const val TAG = "FLTFirebaseDatabase" private const val METHOD_CHANNEL_NAME = "plugins.flutter.io/firebase_database" private val databaseInstanceCache = HashMap() + + // The Dart handler is synchronous, so this only fires when no reply is coming. It matches the + // default transaction timeout of cloud_firestore. + private val TRANSACTION_HANDLER_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(30) } private var listenerCount = 0 @@ -435,6 +457,8 @@ class FirebaseDatabasePlugin : FlutterFirebasePlugin, FlutterPlugin, FirebaseDat override fun onDetachedFromEngine( @NonNull binding: FlutterPluginBinding, ) { + isDetached = true + pendingTransactionHandlers.forEach { it.countDown() } methodChannel.setMethodCallHandler(null) cleanup() } @@ -788,29 +812,9 @@ class FirebaseDatabasePlugin : FlutterFirebasePlugin, FlutterPlugin, FirebaseDat override fun doTransaction( mutableData: com.google.firebase.database.MutableData ): com.google.firebase.database.Transaction.Result { - val semaphore = java.util.concurrent.CountDownLatch(1) - var transactionResult: TransactionHandlerResult? = null - - // Call the Flutter transaction handler on the main thread (required by FlutterJNI) - val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) - mainHandler.post { - val flutterApi = FirebaseDatabaseFlutterApi(messenger) - flutterApi.callTransactionHandler(request.transactionKey, mutableData.value) { - result -> - result.fold( - onSuccess = { transactionResult = it }, - onFailure = { - transactionResult = - TransactionHandlerResult(value = null, aborted = true, exception = true) - }) - semaphore.countDown() - } - } - - semaphore.await() - val result = - transactionResult ?: return com.google.firebase.database.Transaction.abort() + callTransactionHandler(request.transactionKey, mutableData.value) + ?: return com.google.firebase.database.Transaction.abort() if (result.aborted || result.exception) { return com.google.firebase.database.Transaction.abort() @@ -1055,6 +1059,50 @@ class FirebaseDatabasePlugin : FlutterFirebasePlugin, FlutterPlugin, FirebaseDat return database } + // Dart transaction handlers that RTDB run-loop threads are waiting on. + private val pendingTransactionHandlers: MutableSet = ConcurrentHashMap.newKeySet() + @Volatile private var isDetached = false + + /** + * Runs the Dart handler of a transaction and waits for its result. + * + * The RTDB SDK calls this on its run-loop thread, so every other operation on the database waits + * until it returns. Nothing replies when the engine is destroyed or its messenger detached while + * the handler is pending, so the wait times out, and [onDetachedFromEngine] releases it at once. + * + * @return the handler's result, or `null` to abort the transaction. + */ + internal fun callTransactionHandler( + transactionKey: Long, + value: Any? + ): TransactionHandlerResult? { + val latch = CountDownLatch(1) + pendingTransactionHandlers.add(latch) + try { + // Checked after registering the latch, so a concurrent detach either sees it or is seen here. + if (isDetached) { + return null + } + val transactionResult = AtomicReference() + // FlutterJNI must be called on the main thread. + mainThreadExecutor.execute { + FirebaseDatabaseFlutterApi(messenger).callTransactionHandler(transactionKey, value) { + transactionResult.set( + it.getOrElse { + TransactionHandlerResult(value = null, aborted = true, exception = true) + }) + latch.countDown() + } + } + if (!latch.await(transactionHandlerTimeoutMillis, TimeUnit.MILLISECONDS)) { + Log.w(TAG, "The Dart transaction handler did not reply, aborting the transaction.") + } + return transactionResult.get() + } finally { + pendingTransactionHandlers.remove(latch) + } + } + // Store transaction requests for later retrieval private val transactionRequests = mutableMapOf() diff --git a/packages/firebase_database/firebase_database/android/src/test/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePluginTest.kt b/packages/firebase_database/firebase_database/android/src/test/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePluginTest.kt new file mode 100644 index 000000000000..c9504724f61a --- /dev/null +++ b/packages/firebase_database/firebase_database/android/src/test/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePluginTest.kt @@ -0,0 +1,109 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.firebase.database + +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.BinaryMessenger.BinaryMessageHandler +import io.flutter.plugin.common.BinaryMessenger.BinaryReply +import java.nio.ByteBuffer +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class FirebaseDatabasePluginTest { + /** Records the transaction handler calls sent to Dart, which only replies when told to. */ + private class FakeDartMessenger : BinaryMessenger { + val pendingReplies = LinkedBlockingQueue() + + override fun send(channel: String, message: ByteBuffer?) {} + + override fun send(channel: String, message: ByteBuffer?, callback: BinaryReply?) { + callback?.let { pendingReplies.add(it) } + } + + override fun setMessageHandler(channel: String, handler: BinaryMessageHandler?) {} + } + + private val messenger = FakeDartMessenger() + // The plugin only uses the messenger. The constructor is called reflectively because Kotlin + // rejects null for its other, @NonNull, parameters. + private val binding = + FlutterPluginBinding::class + .java + .constructors + .single() + .newInstance(null, null, messenger, null, null, null, null) as FlutterPluginBinding + private val rtdbRunLoop = Executors.newSingleThreadExecutor() + + @After + fun tearDown() { + rtdbRunLoop.shutdownNow() + } + + private fun attachedPlugin(timeoutMillis: Long): FirebaseDatabasePlugin { + // Runs the main-thread work inline, so the fake Dart side receives calls synchronously. + val plugin = FirebaseDatabasePlugin(Executor { it.run() }, timeoutMillis) + plugin.onAttachedToEngine(binding) + return plugin + } + + /** Calls the handler from a separate thread, as the RTDB SDK does from its run loop. */ + private fun callTransactionHandlerOnRunLoop( + plugin: FirebaseDatabasePlugin + ): Future = + rtdbRunLoop.submit { plugin.callTransactionHandler(1, "current") } + + @Test + fun callTransactionHandler_returnsTheResultFromDart() { + val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(1)) + + val pending = callTransactionHandlerOnRunLoop(plugin) + val reply = messenger.pendingReplies.poll(5, TimeUnit.SECONDS)!! + val dartResult = TransactionHandlerResult(value = "updated", aborted = false, exception = false) + reply.reply( + FirebaseDatabaseFlutterApi.codec.encodeMessage(listOf(dartResult))?.apply { rewind() }) + + assertEquals(dartResult, pending.get(5, TimeUnit.SECONDS)) + } + + @Test(timeout = 10_000) + fun callTransactionHandler_abortsWhenDartNeverReplies() { + val plugin = attachedPlugin(timeoutMillis = 100) + + assertNull(callTransactionHandlerOnRunLoop(plugin).get()) + } + + @Test(timeout = 10_000) + fun onDetachedFromEngine_releasesAPendingTransactionHandler() { + val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(10)) + + val pending = callTransactionHandlerOnRunLoop(plugin) + // Dart received the call, and the engine goes away before it replies. + messenger.pendingReplies.take() + plugin.onDetachedFromEngine(binding) + + assertNull(pending.get()) + } + + @Test(timeout = 10_000) + fun callTransactionHandler_abortsWithoutCallingDartAfterDetach() { + val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(10)) + plugin.onDetachedFromEngine(binding) + + assertNull(callTransactionHandlerOnRunLoop(plugin).get()) + assertTrue(messenger.pendingReplies.isEmpty()) + } +} From 67a1098f1b904f5b74eb6200849b077cff0fb551 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 15:34:17 +0200 Subject: [PATCH 2/2] 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. --- .../database/FirebaseDatabasePlugin.kt | 167 +++++++++++------- .../database/FirebaseDatabasePluginTest.kt | 125 +++++++++---- 2 files changed, 189 insertions(+), 103 deletions(-) diff --git a/packages/firebase_database/firebase_database/android/src/main/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.kt b/packages/firebase_database/firebase_database/android/src/main/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.kt index 9d7264b303a1..9d457a1a5f50 100644 --- a/packages/firebase_database/firebase_database/android/src/main/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.kt +++ b/packages/firebase_database/firebase_database/android/src/main/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.kt @@ -52,8 +52,10 @@ internal constructor( private const val METHOD_CHANNEL_NAME = "plugins.flutter.io/firebase_database" private val databaseInstanceCache = HashMap() - // The Dart handler is synchronous, so this only fires when no reply is coming. It matches the - // default transaction timeout of cloud_firestore. + // How long a transaction waits for its Dart handler, which is synchronous. It is reached when + // the isolate is paused in a debugger, the main thread is blocked, or the reply is lost, as + // during a hot restart or once the engine is detached. It matches the default transaction + // timeout of cloud_firestore. private val TRANSACTION_HANDLER_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(30) } @@ -82,7 +84,9 @@ internal constructor( } } - private fun initPluginInstance(messenger: BinaryMessenger) { + internal fun initPluginInstance(messenger: BinaryMessenger) { + // The plugin can be attached again after a detach. + isDetached = false FlutterFirebasePluginRegistry.registerPlugin(METHOD_CHANNEL_NAME, this) this.messenger = messenger @@ -457,6 +461,10 @@ internal constructor( override fun onDetachedFromEngine( @NonNull binding: FlutterPluginBinding, ) { + detachFromEngine() + } + + internal fun detachFromEngine() { isDetached = true pendingTransactionHandlers.forEach { it.countDown() } methodChannel.setMethodCallHandler(null) @@ -807,51 +815,7 @@ internal constructor( transactionRequests[request.transactionKey] = request // Start the transaction - simplified approach like iOS - reference.runTransaction( - object : com.google.firebase.database.Transaction.Handler { - override fun doTransaction( - mutableData: com.google.firebase.database.MutableData - ): com.google.firebase.database.Transaction.Result { - val result = - callTransactionHandler(request.transactionKey, mutableData.value) - ?: return com.google.firebase.database.Transaction.abort() - - if (result.aborted || result.exception) { - return com.google.firebase.database.Transaction.abort() - } - - mutableData.value = result.value - return com.google.firebase.database.Transaction.success(mutableData) - } - - override fun onComplete( - error: com.google.firebase.database.DatabaseError?, - committed: Boolean, - currentData: com.google.firebase.database.DataSnapshot? - ) { - // Store the transaction result for later retrieval - val result = - mapOf( - "committed" to committed, - "snapshot" to - mapOf( - "value" to currentData?.value, - "key" to currentData?.key, - "exists" to currentData?.exists())) - transactionResults[request.transactionKey] = result - - // Complete the transaction - simplified like iOS - if (error != null) { - val ex = FlutterFirebaseDatabaseException.fromDatabaseError(error) - callback( - KotlinResult.failure( - FlutterError("firebase_database", ex.message, ex.additionalData))) - } else { - callback(KotlinResult.success(Unit)) - } - } - }, - request.applyLocally) + reference.runTransaction(transactionHandler(request, callback), request.applyLocally) } catch (e: Exception) { // Convert generic exceptions to FlutterFirebaseDatabaseException for proper error handling val flutterException = @@ -1059,6 +1023,65 @@ internal constructor( return database } + /** Bridges the RTDB transaction of [request] to its Dart handler, and reports to [callback]. */ + internal fun transactionHandler( + request: TransactionRequest, + callback: (KotlinResult) -> Unit + ): com.google.firebase.database.Transaction.Handler = + object : com.google.firebase.database.Transaction.Handler { + // Why the Dart handler gave no result. Reported from onComplete rather than thrown, because + // the SDK turns an exception thrown here into an uncoded "user code" error. + @Volatile private var handlerFailure: FlutterFirebaseDatabaseException? = null + + override fun doTransaction( + mutableData: com.google.firebase.database.MutableData + ): com.google.firebase.database.Transaction.Result { + val result = + try { + callTransactionHandler(request.transactionKey, mutableData.value) + } catch (e: FlutterFirebaseDatabaseException) { + handlerFailure = e + return com.google.firebase.database.Transaction.abort() + } + + if (result.aborted || result.exception) { + return com.google.firebase.database.Transaction.abort() + } + + mutableData.value = result.value + return com.google.firebase.database.Transaction.success(mutableData) + } + + override fun onComplete( + error: com.google.firebase.database.DatabaseError?, + committed: Boolean, + currentData: com.google.firebase.database.DataSnapshot? + ) { + // Store the transaction result for later retrieval + val result = + mapOf( + "committed" to committed, + "snapshot" to + mapOf( + "value" to currentData?.value, + "key" to currentData?.key, + "exists" to currentData?.exists())) + transactionResults[request.transactionKey] = result + + // Complete the transaction - simplified like iOS + val ex = + handlerFailure + ?: error?.let { FlutterFirebaseDatabaseException.fromDatabaseError(it) } + if (ex != null) { + callback( + KotlinResult.failure( + FlutterError("firebase_database", ex.message, ex.additionalData))) + } else { + callback(KotlinResult.success(Unit)) + } + } + } + // Dart transaction handlers that RTDB run-loop threads are waiting on. private val pendingTransactionHandlers: MutableSet = ConcurrentHashMap.newKeySet() @Volatile private var isDetached = false @@ -1070,34 +1093,42 @@ internal constructor( * until it returns. Nothing replies when the engine is destroyed or its messenger detached while * the handler is pending, so the wait times out, and [onDetachedFromEngine] releases it at once. * - * @return the handler's result, or `null` to abort the transaction. + * @throws FlutterFirebaseDatabaseException `deadline-exceeded` when Dart does not reply in time, + * or `cancelled` when the plugin is detached from its engine first. */ - internal fun callTransactionHandler( - transactionKey: Long, - value: Any? - ): TransactionHandlerResult? { + internal fun callTransactionHandler(transactionKey: Long, value: Any?): TransactionHandlerResult { val latch = CountDownLatch(1) pendingTransactionHandlers.add(latch) try { // Checked after registering the latch, so a concurrent detach either sees it or is seen here. - if (isDetached) { - return null - } - val transactionResult = AtomicReference() - // FlutterJNI must be called on the main thread. - mainThreadExecutor.execute { - FirebaseDatabaseFlutterApi(messenger).callTransactionHandler(transactionKey, value) { - transactionResult.set( - it.getOrElse { - TransactionHandlerResult(value = null, aborted = true, exception = true) - }) - latch.countDown() + if (!isDetached) { + val transactionResult = AtomicReference() + // FlutterJNI must be called on the main thread. + mainThreadExecutor.execute { + FirebaseDatabaseFlutterApi(messenger).callTransactionHandler(transactionKey, value) { + transactionResult.set( + it.getOrElse { + TransactionHandlerResult(value = null, aborted = true, exception = true) + }) + latch.countDown() + } + } + latch.await(transactionHandlerTimeoutMillis, TimeUnit.MILLISECONDS) + // Read after the wait, so a reply that arrives just after the timeout is still used. + transactionResult.get()?.let { + return it } } - if (!latch.await(transactionHandlerTimeoutMillis, TimeUnit.MILLISECONDS)) { - Log.w(TAG, "The Dart transaction handler did not reply, aborting the transaction.") + if (isDetached) { + throw FlutterFirebaseDatabaseException( + "cancelled", + "The transaction was cancelled because the Flutter engine was detached before its " + + "handler replied.") } - return transactionResult.get() + Log.w(TAG, "The Dart transaction handler did not reply in time, failing the transaction.") + throw FlutterFirebaseDatabaseException( + "deadline-exceeded", + "The transaction handler did not reply within $transactionHandlerTimeoutMillis ms.") } finally { pendingTransactionHandlers.remove(latch) } diff --git a/packages/firebase_database/firebase_database/android/src/test/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePluginTest.kt b/packages/firebase_database/firebase_database/android/src/test/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePluginTest.kt index c9504724f61a..d354ef719c43 100644 --- a/packages/firebase_database/firebase_database/android/src/test/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePluginTest.kt +++ b/packages/firebase_database/firebase_database/android/src/test/kotlin/io/flutter/plugins/firebase/database/FirebaseDatabasePluginTest.kt @@ -4,7 +4,10 @@ package io.flutter.plugins.firebase.database -import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding +import com.google.firebase.database.InternalHelpers +import com.google.firebase.database.MutableData +import com.google.firebase.database.Transaction +import com.google.firebase.database.snapshot.NodeUtilities import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger.BinaryMessageHandler import io.flutter.plugin.common.BinaryMessenger.BinaryReply @@ -14,9 +17,10 @@ import java.util.concurrent.Executors import java.util.concurrent.Future import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit +import kotlin.Result as KotlinResult import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -37,15 +41,23 @@ class FirebaseDatabasePluginTest { override fun setMessageHandler(channel: String, handler: BinaryMessageHandler?) {} } + /** A transaction as the RTDB SDK drives it, with what the plugin reports to Dart. */ + private inner class TransactionRun(plugin: FirebaseDatabasePlugin) { + var reported: KotlinResult? = null + val handler = + plugin.transactionHandler( + TransactionRequest(path = "counter", transactionKey = 1, applyLocally = true)) { + reported = it + } + val mutableData: MutableData = + InternalHelpers.createMutableData(NodeUtilities.NodeFromJSON("current")) + + /** Runs the handler on a separate thread, as the RTDB SDK does from its run loop. */ + fun doTransactionOnRunLoop(): Future = + rtdbRunLoop.submit { handler.doTransaction(mutableData) } + } + private val messenger = FakeDartMessenger() - // The plugin only uses the messenger. The constructor is called reflectively because Kotlin - // rejects null for its other, @NonNull, parameters. - private val binding = - FlutterPluginBinding::class - .java - .constructors - .single() - .newInstance(null, null, messenger, null, null, null, null) as FlutterPluginBinding private val rtdbRunLoop = Executors.newSingleThreadExecutor() @After @@ -56,54 +68,97 @@ class FirebaseDatabasePluginTest { private fun attachedPlugin(timeoutMillis: Long): FirebaseDatabasePlugin { // Runs the main-thread work inline, so the fake Dart side receives calls synchronously. val plugin = FirebaseDatabasePlugin(Executor { it.run() }, timeoutMillis) - plugin.onAttachedToEngine(binding) + plugin.initPluginInstance(messenger) return plugin } - /** Calls the handler from a separate thread, as the RTDB SDK does from its run loop. */ - private fun callTransactionHandlerOnRunLoop( - plugin: FirebaseDatabasePlugin - ): Future = - rtdbRunLoop.submit { plugin.callTransactionHandler(1, "current") } + private fun replyFromDart(result: TransactionHandlerResult) { + messenger.pendingReplies + .poll(5, TimeUnit.SECONDS)!! + .reply(FirebaseDatabaseFlutterApi.codec.encodeMessage(listOf(result))?.apply { rewind() }) + } - @Test - fun callTransactionHandler_returnsTheResultFromDart() { - val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(1)) + /** Asserts that Dart gets a `FirebaseException` with [code]. */ + private fun assertReportedFailure(code: String, reported: KotlinResult?) { + val error = reported!!.exceptionOrNull() as FlutterError + // The Dart side reads the code of a `FirebaseException` from the details. + assertEquals("firebase_database", error.code) + assertEquals(code, (error.details as Map<*, *>)["code"]) + } - val pending = callTransactionHandlerOnRunLoop(plugin) - val reply = messenger.pendingReplies.poll(5, TimeUnit.SECONDS)!! - val dartResult = TransactionHandlerResult(value = "updated", aborted = false, exception = false) - reply.reply( - FirebaseDatabaseFlutterApi.codec.encodeMessage(listOf(dartResult))?.apply { rewind() }) + @Test(timeout = 10_000) + fun transaction_commitsTheValueFromDart() { + val run = TransactionRun(attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(1))) + + val pending = run.doTransactionOnRunLoop() + replyFromDart(TransactionHandlerResult(value = "updated", aborted = false, exception = false)) - assertEquals(dartResult, pending.get(5, TimeUnit.SECONDS)) + assertTrue(pending.get().isSuccess) + assertEquals("updated", run.mutableData.value) + run.handler.onComplete(null, true, null) + assertTrue(run.reported!!.isSuccess) } @Test(timeout = 10_000) - fun callTransactionHandler_abortsWhenDartNeverReplies() { - val plugin = attachedPlugin(timeoutMillis = 100) + fun transaction_abortedByDartCompletesWithoutAnError() { + val run = TransactionRun(attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(1))) + + val pending = run.doTransactionOnRunLoop() + replyFromDart(TransactionHandlerResult(value = null, aborted = true, exception = false)) - assertNull(callTransactionHandlerOnRunLoop(plugin).get()) + assertFalse(pending.get().isSuccess) + run.handler.onComplete(null, false, null) + // Dart reads `committed: false`. + assertTrue(run.reported!!.isSuccess) } @Test(timeout = 10_000) - fun onDetachedFromEngine_releasesAPendingTransactionHandler() { + fun transaction_failsWithDeadlineExceededWhenDartNeverReplies() { + val run = TransactionRun(attachedPlugin(timeoutMillis = 100)) + + assertFalse(run.doTransactionOnRunLoop().get().isSuccess) + run.handler.onComplete(null, false, null) + + assertReportedFailure("deadline-exceeded", run.reported) + } + + @Test(timeout = 10_000) + fun detachFromEngine_failsAPendingTransaction() { val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(10)) + val run = TransactionRun(plugin) - val pending = callTransactionHandlerOnRunLoop(plugin) + val pending = run.doTransactionOnRunLoop() // Dart received the call, and the engine goes away before it replies. messenger.pendingReplies.take() - plugin.onDetachedFromEngine(binding) + plugin.detachFromEngine() - assertNull(pending.get()) + assertFalse(pending.get().isSuccess) + run.handler.onComplete(null, false, null) + assertReportedFailure("cancelled", run.reported) } @Test(timeout = 10_000) - fun callTransactionHandler_abortsWithoutCallingDartAfterDetach() { + fun transaction_failsWithoutCallingDartAfterDetach() { val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(10)) - plugin.onDetachedFromEngine(binding) + plugin.detachFromEngine() + val run = TransactionRun(plugin) - assertNull(callTransactionHandlerOnRunLoop(plugin).get()) + assertFalse(run.doTransactionOnRunLoop().get().isSuccess) assertTrue(messenger.pendingReplies.isEmpty()) + run.handler.onComplete(null, false, null) + assertReportedFailure("cancelled", run.reported) + } + + @Test(timeout = 10_000) + fun initPluginInstance_callsDartAgainAfterADetach() { + val plugin = attachedPlugin(timeoutMillis = TimeUnit.MINUTES.toMillis(1)) + plugin.detachFromEngine() + plugin.initPluginInstance(messenger) + val run = TransactionRun(plugin) + + val pending = run.doTransactionOnRunLoop() + replyFromDart(TransactionHandlerResult(value = "updated", aborted = false, exception = false)) + + assertTrue(pending.get().isSuccess) } }