From eb555010f3386fb34a5f68eaa810abdccbb468bc Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 21 Oct 2026 13:23:42 +0000 Subject: [PATCH 1/4] fix(database,ios): keep null update deletes and bound transaction waits Null values in update() were stripped before updateChildValues, so deletes were no-ops. Transaction handlers could also block the SDK queue forever if Dart never replied. --- .../database_reference_e2e.dart | 10 ++++ .../FLTFirebaseDatabasePlugin.swift | 60 +++++++++++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart b/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart index deddc05b6e32..02c159fc3bd7 100644 --- a/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart +++ b/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart @@ -106,6 +106,16 @@ void setupDatabaseReferenceTests() { 'bar': newValue, }); }); + + test('removes a child if updated to null', () async { + final ref = database.ref('tests/update-null'); + await ref.remove(); + await ref.set({'foo': 'bar', 'baz': 'qux'}); + await ref.update({'foo': null}); + final actual = await ref.get(); + + expect(actual.value, {'baz': 'qux'}); + }); }); group('runTransaction()', () { diff --git a/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift b/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift index cbcb5a120822..d8ac2881ef38 100644 --- a/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift +++ b/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift @@ -31,6 +31,9 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug private var streamHandlers: [String: FLTFirebaseDatabaseObserveStreamHandler] = [:] private var listenerCount: Int = 0 private var transactionResults: [Int64: [String: Any?]] = [:] + private let pendingTransactionLock = NSLock() + private var pendingTransactionSemaphores: [DispatchSemaphore] = [] + private var isDetached = false /// Wraps a native error so that its Realtime Database code reaches Dart. /// @@ -77,7 +80,7 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug } func cleanup(completion: (() -> Void)? = nil) { - // No-op cleanup for now + releasePendingTransactionWaits() completion?() } @@ -89,6 +92,9 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug public func didReinitializeFirebaseCore(_ completion: @escaping () -> Void) { cleanup() + pendingTransactionLock.lock() + isDetached = false + pendingTransactionLock.unlock() completion() } @@ -256,7 +262,9 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug let database = getDatabaseFromPigeonApp(app) let reference = database.reference(withPath: request.path) - let values = request.value.compactMapValues { $0 } + let values = Dictionary( + uniqueKeysWithValues: request.value.map { ($0.key, $0.value ?? NSNull()) } + ) reference.updateChildValues(values) { error, _ in if let error { @@ -296,7 +304,24 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug let semaphore = DispatchSemaphore(value: 0) var transactionResult: TransactionHandlerResult? + self.pendingTransactionLock.lock() + self.pendingTransactionSemaphores.append(semaphore) + let detached = self.isDetached + self.pendingTransactionLock.unlock() + + if detached { + self.removePendingTransactionSemaphore(semaphore) + return TransactionResult.abort() + } + DispatchQueue.main.async { + self.pendingTransactionLock.lock() + let detached = self.isDetached + self.pendingTransactionLock.unlock() + if detached { + return + } + let flutterApi = FirebaseDatabaseFlutterApi(binaryMessenger: self.binaryMessenger) flutterApi.callTransactionHandler( transactionKey: request.transactionKey, @@ -315,7 +340,13 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug } } - semaphore.wait() + // 30s matches cloud_firestore / Android. + let waitResult = semaphore.wait(timeout: .now() + .seconds(30)) + let detachedAfterWait = self.removePendingTransactionSemaphore(semaphore) + + if waitResult == .timedOut || detachedAfterWait { + return TransactionResult.abort() + } guard let result = transactionResult else { return TransactionResult.abort() @@ -412,7 +443,9 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug let database = getDatabaseFromPigeonApp(app) let reference = database.reference(withPath: request.path) - let values = request.value.compactMapValues { $0 } + let values = Dictionary( + uniqueKeysWithValues: request.value.map { ($0.key, $0.value ?? NSNull()) } + ) reference.onDisconnectUpdateChildValues(values) { error, _ in if let error { @@ -744,6 +777,25 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug // MARK: - Helper Methods + private func releasePendingTransactionWaits() { + pendingTransactionLock.lock() + isDetached = true + let semaphores = pendingTransactionSemaphores + pendingTransactionSemaphores.removeAll() + pendingTransactionLock.unlock() + for semaphore in semaphores { + semaphore.signal() + } + } + + private func removePendingTransactionSemaphore(_ semaphore: DispatchSemaphore) -> Bool { + pendingTransactionLock.lock() + pendingTransactionSemaphores.removeAll { $0 === semaphore } + let detached = isDetached + pendingTransactionLock.unlock() + return detached + } + private func databaseInstanceKey(_ app: DatabasePigeonFirebaseApp) -> String { app.appName + (app.databaseURL ?? "") } From 062f24eed7e27afa7357da1b082fe810bd9be874 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 21 Oct 2026 14:17:54 +0000 Subject: [PATCH 2/4] fix(database,ios): fail timed-out transaction waits instead of aborting --- .../database_reference_e2e.dart | 30 ++++++ .../FLTFirebaseDatabasePlugin.swift | 93 +++++++++++++------ 2 files changed, 97 insertions(+), 26 deletions(-) diff --git a/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart b/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart index 02c159fc3bd7..8438bd0bf8de 100644 --- a/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart +++ b/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart @@ -188,6 +188,36 @@ void setupDatabaseReferenceTests() { skip: kIsWeb, ); + test( + 'fails with deadline-exceeded when the handler does not reply in time', + () async { + final ref = _uniqueRef('transaction-handler-timeout'); + await ref.set('unchanged'); + + await expectLater( + ref.runTransaction((value) { + final until = DateTime.now().add(const Duration(seconds: 31)); + while (DateTime.now().isBefore(until)) {} + return Transaction.success(value); + }), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'deadline-exceeded', + ), + ), + ); + + final snapshot = await ref.get(); + expect(snapshot.value, 'unchanged'); + }, + timeout: const Timeout(Duration(seconds: 45)), + skip: kIsWeb || + (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.macOS), + ); + test('does not emit local transaction events when disabled', () async { final ref = _uniqueRef('transaction-apply-locally-false'); await ref.set({'count': 0}); diff --git a/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift b/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift index d8ac2881ef38..57f8313a5930 100644 --- a/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift +++ b/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift @@ -34,6 +34,7 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug private let pendingTransactionLock = NSLock() private var pendingTransactionSemaphores: [DispatchSemaphore] = [] private var isDetached = false + private static let transactionHandlerTimeoutSeconds = 30 /// Wraps a native error so that its Realtime Database code reaches Dart. /// @@ -91,10 +92,15 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug // MARK: - FLTFirebasePlugin public func didReinitializeFirebaseCore(_ completion: @escaping () -> Void) { - cleanup() pendingTransactionLock.lock() + isDetached = true + let semaphores = pendingTransactionSemaphores + pendingTransactionSemaphores.removeAll() isDetached = false pendingTransactionLock.unlock() + for semaphore in semaphores { + semaphore.signal() + } completion() } @@ -262,11 +268,7 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug let database = getDatabaseFromPigeonApp(app) let reference = database.reference(withPath: request.path) - let values = Dictionary( - uniqueKeysWithValues: request.value.map { ($0.key, $0.value ?? NSNull()) } - ) - - reference.updateChildValues(values) { error, _ in + reference.updateChildValues(updateValues(request)) { error, _ in if let error { completion(.failure(self.createFlutterError(error))) } else { @@ -299,6 +301,8 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug let database = getDatabaseFromPigeonApp(app) let reference = database.reference(withPath: request.path) + var transactionError: PigeonError? + reference.runTransactionBlock( { currentData in let semaphore = DispatchSemaphore(value: 0) @@ -310,15 +314,17 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug self.pendingTransactionLock.unlock() if detached { - self.removePendingTransactionSemaphore(semaphore) + _ = self.removePendingTransactionSemaphore(semaphore) + transactionError = Self.transactionCancelledError() return TransactionResult.abort() } DispatchQueue.main.async { self.pendingTransactionLock.lock() - let detached = self.isDetached + let pending = + !self.isDetached && self.pendingTransactionSemaphores.contains { $0 === semaphore } self.pendingTransactionLock.unlock() - if detached { + if !pending { return } @@ -327,28 +333,33 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug transactionKey: request.transactionKey, snapshotValue: currentData.value ) { result in + let handlerResult: TransactionHandlerResult switch result { - case .success(let handlerResult): - transactionResult = handlerResult + case .success(let value): + handlerResult = value case .failure(let error): print("Transaction handler error: \(error)") - transactionResult = TransactionHandlerResult( + handlerResult = TransactionHandlerResult( value: nil, aborted: true, exception: true ) } + self.pendingTransactionLock.lock() + transactionResult = handlerResult + self.pendingTransactionLock.unlock() semaphore.signal() } } - // 30s matches cloud_firestore / Android. - let waitResult = semaphore.wait(timeout: .now() + .seconds(30)) - let detachedAfterWait = self.removePendingTransactionSemaphore(semaphore) + _ = semaphore.wait(timeout: .now() + .seconds(Self.transactionHandlerTimeoutSeconds)) + let cancelled = self.removePendingTransactionSemaphore(semaphore) - if waitResult == .timedOut || detachedAfterWait { - return TransactionResult.abort() - } + self.pendingTransactionLock.lock() + let handlerResult = transactionResult + self.pendingTransactionLock.unlock() - guard let result = transactionResult else { + guard let result = handlerResult, !cancelled else { + transactionError = + cancelled ? Self.transactionCancelledError() : Self.transactionTimeoutError() return TransactionResult.abort() } @@ -360,6 +371,11 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug return TransactionResult.success(withValue: currentData) }, andCompletionBlock: { error, committed, snapshot in + if let transactionError { + completion(.failure(transactionError)) + return + } + if let error { completion(.failure(self.createFlutterError(error))) return @@ -443,11 +459,7 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug let database = getDatabaseFromPigeonApp(app) let reference = database.reference(withPath: request.path) - let values = Dictionary( - uniqueKeysWithValues: request.value.map { ($0.key, $0.value ?? NSNull()) } - ) - - reference.onDisconnectUpdateChildValues(values) { error, _ in + reference.onDisconnectUpdateChildValues(updateValues(request)) { error, _ in if let error { completion(.failure(self.createFlutterError(error))) } else { @@ -790,10 +802,39 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug private func removePendingTransactionSemaphore(_ semaphore: DispatchSemaphore) -> Bool { pendingTransactionLock.lock() + let released = !pendingTransactionSemaphores.contains { $0 === semaphore } pendingTransactionSemaphores.removeAll { $0 === semaphore } - let detached = isDetached + let cancelled = released || isDetached pendingTransactionLock.unlock() - return detached + return cancelled + } + + private static func transactionTimeoutError() -> PigeonError { + transactionError( + code: "deadline-exceeded", + message: + "The transaction handler did not reply within \(transactionHandlerTimeoutSeconds) s." + ) + } + + private static func transactionCancelledError() -> PigeonError { + transactionError( + code: "cancelled", + message: + "The transaction was cancelled because the Flutter engine was detached before its handler replied." + ) + } + + private static func transactionError(code: String, message: String) -> PigeonError { + PigeonError( + code: code, + message: message, + details: ["code": code, "message": message] + ) + } + + private func updateValues(_ request: UpdateRequest) -> [String: Any] { + Dictionary(uniqueKeysWithValues: request.value.map { ($0.key, $0.value ?? NSNull()) }) } private func databaseInstanceKey(_ app: DatabasePigeonFirebaseApp) -> String { From 158295f13fb6f7ff1e207ab35662b8447ac77cfe Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Thu, 22 Oct 2026 08:49:28 +0000 Subject: [PATCH 3/4] fix(database,ios): drop the null-update helper Dart nulls already arrive as NSNull, and compactMapValues keeps them, so updateChildValues already deletes those children. --- .../integration_test/database_reference_e2e.dart | 10 ---------- .../FLTFirebaseDatabasePlugin.swift | 12 ++++++------ 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart b/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart index 8438bd0bf8de..7b6c402077c2 100644 --- a/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart +++ b/packages/firebase_database/firebase_database/example/integration_test/database_reference_e2e.dart @@ -106,16 +106,6 @@ void setupDatabaseReferenceTests() { 'bar': newValue, }); }); - - test('removes a child if updated to null', () async { - final ref = database.ref('tests/update-null'); - await ref.remove(); - await ref.set({'foo': 'bar', 'baz': 'qux'}); - await ref.update({'foo': null}); - final actual = await ref.get(); - - expect(actual.value, {'baz': 'qux'}); - }); }); group('runTransaction()', () { diff --git a/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift b/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift index 57f8313a5930..de220fbd6179 100644 --- a/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift +++ b/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift @@ -268,7 +268,9 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug let database = getDatabaseFromPigeonApp(app) let reference = database.reference(withPath: request.path) - reference.updateChildValues(updateValues(request)) { error, _ in + let values = request.value.compactMapValues { $0 } + + reference.updateChildValues(values) { error, _ in } if let error { completion(.failure(self.createFlutterError(error))) } else { @@ -459,7 +461,9 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug let database = getDatabaseFromPigeonApp(app) let reference = database.reference(withPath: request.path) - reference.onDisconnectUpdateChildValues(updateValues(request)) { error, _ in + let values = request.value.compactMapValues { $0 } + + reference.onDisconnectUpdateChildValues(values) { error, _ in } if let error { completion(.failure(self.createFlutterError(error))) } else { @@ -833,10 +837,6 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug ) } - private func updateValues(_ request: UpdateRequest) -> [String: Any] { - Dictionary(uniqueKeysWithValues: request.value.map { ($0.key, $0.value ?? NSNull()) }) - } - private func databaseInstanceKey(_ app: DatabasePigeonFirebaseApp) -> String { app.appName + (app.databaseURL ?? "") } From 282767b263a96deeabde8950b006a17e99445912 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Thu, 22 Oct 2026 08:59:38 +0000 Subject: [PATCH 4/4] fix(database,ios): close the update completion handlers The closures were closed before their bodies, so the file did not compile and update never completed. --- .../Sources/firebase_database/FLTFirebaseDatabasePlugin.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift b/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift index de220fbd6179..8ca99f8869fa 100644 --- a/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift +++ b/packages/firebase_database/firebase_database/ios/firebase_database/Sources/firebase_database/FLTFirebaseDatabasePlugin.swift @@ -270,7 +270,7 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug let values = request.value.compactMapValues { $0 } - reference.updateChildValues(values) { error, _ in } + reference.updateChildValues(values) { error, _ in if let error { completion(.failure(self.createFlutterError(error))) } else { @@ -463,7 +463,7 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug let values = request.value.compactMapValues { $0 } - reference.onDisconnectUpdateChildValues(values) { error, _ in } + reference.onDisconnectUpdateChildValues(values) { error, _ in if let error { completion(.failure(self.createFlutterError(error))) } else {