Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,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<FirebaseException>().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});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ 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
private static let transactionHandlerTimeoutSeconds = 30

/// Wraps a native error so that its Realtime Database code reaches Dart.
///
Expand Down Expand Up @@ -77,7 +81,7 @@ public class FLTFirebaseDatabasePlugin: NSObject, FlutterPlugin, FLTFirebasePlug
}

func cleanup(completion: (() -> Void)? = nil) {
// No-op cleanup for now
releasePendingTransactionWaits()
completion?()
}

Expand All @@ -88,7 +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()
}

Expand Down Expand Up @@ -291,33 +303,65 @@ 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)
var transactionResult: TransactionHandlerResult?

self.pendingTransactionLock.lock()
self.pendingTransactionSemaphores.append(semaphore)
let detached = self.isDetached
self.pendingTransactionLock.unlock()

if detached {
_ = self.removePendingTransactionSemaphore(semaphore)
transactionError = Self.transactionCancelledError()
return TransactionResult.abort()
}

DispatchQueue.main.async {
self.pendingTransactionLock.lock()
let pending =
!self.isDetached && self.pendingTransactionSemaphores.contains { $0 === semaphore }
self.pendingTransactionLock.unlock()
if !pending {
return
}

let flutterApi = FirebaseDatabaseFlutterApi(binaryMessenger: self.binaryMessenger)
flutterApi.callTransactionHandler(
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()
}
}

semaphore.wait()
_ = semaphore.wait(timeout: .now() + .seconds(Self.transactionHandlerTimeoutSeconds))
let cancelled = self.removePendingTransactionSemaphore(semaphore)

guard let result = transactionResult else {
self.pendingTransactionLock.lock()
let handlerResult = transactionResult
self.pendingTransactionLock.unlock()

guard let result = handlerResult, !cancelled else {
transactionError =
cancelled ? Self.transactionCancelledError() : Self.transactionTimeoutError()
return TransactionResult.abort()
}

Expand All @@ -329,6 +373,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
Expand Down Expand Up @@ -744,6 +793,50 @@ 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()
let released = !pendingTransactionSemaphores.contains { $0 === semaphore }
pendingTransactionSemaphores.removeAll { $0 === semaphore }
let cancelled = released || isDetached
pendingTransactionLock.unlock()
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 databaseInstanceKey(_ app: DatabasePigeonFirebaseApp) -> String {
app.appName + (app.databaseURL ?? "")
}
Expand Down
Loading