Skip to content

[cloud_firestore] iOS: transaction.get() after the native timeout aborts the app ("A transaction object cannot be used after its update callback has been invoked") #18755

Description

@ewankirk

Is there an existing issue for this?

  • I have searched the existing issues.

Which plugins are affected?

Database

Which platforms are affected?

iOS

Description

A Dart transaction.get() can reach the native FIRTransaction after the transaction's update block has already returned. Firestore then fails an internal assertion and the process aborts (SIGABRT). It's an uncatchable native crash, not a Dart exception:

NSInternalInconsistencyException - FIRESTORE INTERNAL ASSERTION FAILED: A transaction object cannot be used after its update callback has been invoked. (expected !committed_)

Expected: a get that arrives after the native attempt has ended fails with a catchable FirebaseException (or the transaction fails cleanly), like the existing missing-transaction error.

Actual: the app is killed.

Where it happens (cloud_firestore 6.10.0, iOS sources)

  1. FLTTransactionStreamHandler.m:62: the native update block posts the attempt to Dart, then waits on a semaphore for at most timeout (Dart default 30 s).
  2. FLTTransactionStreamHandler.m:65-115: on timeout it sends Dart a deadline-exceeded error event, then carries on. It applies self.commands and returns nil without setting *pError, so the SDK goes on to commit. Transaction::Commit sets committed_ = true before the commit round trip.
  3. FLTFirebaseFirestorePlugin.m:791 stores the attempt's FIRTransaction in _transactions[transactionId] when the block starts. FLTFirebaseFirestorePlugin.m:796 removes it only in ended(), which runs from the completion block (FLTTransactionStreamHandler.m:141) after the commit finishes.
  4. FLTFirebaseFirestorePlugin.m:667-682 (transactionGetApp) looks the transaction up by id and calls -[FIRTransaction getDocument:error:] with no check that the update block is still running.

So any Dart get that lands between the update block returning and the completion block (the commit round trip) calls Transaction::Lookup → EnsureCommitNotCalled() → assertion → abort. The Dart side has already been told the transaction failed (deadline-exceeded), but the user's handler is an ordinary async closure: it keeps running and issues its get.

How we hit it in production

A TestFlight build, iPhone, iOS 27.0.1. The app was suspended by iOS while a short read-modify-write transaction (one get, one set) was in flight. On resume (the user tapped a notification), the native wait's deadline had long passed, so the block timed out, returned and committed, just as the Dart handler resumed and sent its get. The crash was in the foreground, in the same second as the resume.

Any transaction can hit this whenever the Dart handler runs longer than timeout: app suspension, a slow first get on a poor connection, or a busy UI isolate.

Related

Suggested fix

  • Record per attempt that the update block has returned (for example a flag set, under the same lock as _transactions, right after the semaphore wait). In transactionGetApp, return a FlutterError (for example aborted or deadline-exceeded) instead of calling getDocument: once it's set.
  • On timeout, fail the attempt (set *pError, for example FIRFirestoreErrorCodeDeadlineExceeded or Aborted) instead of returning nil. Returning nil lets the SDK commit a transaction that Dart has already been told failed.
  • Also: resultType and commands are never reset between attempts. If a retried attempt times out, the block applies the previous attempt's commands (computed from the previous attempt's reads) to the new transaction.

Reproducing the issue

Timing-dependent: the get has to land inside the commit round trip after the native wait times out. This sketch hasn't been run as a standalone app; the production crash above is the evidence.

final db = FirebaseFirestore.instance;
final ref = db.doc('repro/doc');

for (var i = 0; i < 50; i++) {
  try {
    await db.runTransaction((tx) async {
      await tx.get(ref); // a read, so the commit has a verify write
      // Outlive the native wait (timeout below), then read again.
      await Future<void>.delayed(Duration(milliseconds: 1000 + i * 5));
      await tx.get(ref);
    }, timeout: const Duration(seconds: 1));
  } on FirebaseException catch (e) {
    debugPrint('attempt $i: ${e.code}'); // deadline-exceeded is expected
  }
}

Run it on a physical iOS device (a network link conditioner with added latency widens the window). Expected: each attempt fails with a catchable error. Actual: sooner or later the app aborts with the assertion above.

The production path: start a transaction, background the app so iOS suspends it before the handler's get, wait well past the timeout, then bring the app back.

Firebase Core version

4.15.0

Flutter Version

3.47.5

Relevant Log Output

Exception Type:  EXC_CRASH (SIGABRT)
Termination Reason: SIGNAL 6 Abort trap: 6

NSInternalInconsistencyException - FIRESTORE INTERNAL ASSERTION FAILED: A transaction object cannot be used after its update callback has been invoked. (expected !committed_)

Crashed thread:
  firebase::firestore::util::ObjcThrowHandler(...)
  firebase::firestore::util::Throw(...)
  firebase::firestore::util::internal::FailAssertion(...)
  firebase::firestore::util::internal::FailAssertion(...)
  firebase::firestore::core::Transaction::EnsureCommitNotCalled() + 104
  firebase::firestore::core::Transaction::Lookup(...) + 56
  -[FIRTransaction getDocument:completion:] + 264
  -[FIRTransaction getDocument:error:] + 200
  __78-[FLTFirebaseFirestorePlugin transactionGetApp:transactionId:path:completion:]_block_invoke + 172 (FLTFirebaseFirestorePlugin.m:682)
  _dispatch_call_block_and_release
  _dispatch_client_callout
  _dispatch_queue_override_invoke
  _dispatch_root_queue_drain
  _dispatch_worker_thread2
  _pthread_wqthread


No other thread was inside a transaction update block at the time of the crash: the block had already returned.

Flutter dependencies

Expand Flutter dependencies snippet
Replace this line with the contents of your `flutter pub deps -- --style=compact`.

Additional context and comments

Our workaround: don't start a transaction unless the app is in the foreground (AppLifecycleState.resumed), and run anything held back on resume. That avoids the suspension path, but not a slow handler in the foreground.

The macOS implementation appears to share these Darwin sources, so it's probably affected too (not tested).

Activity

  1. SelaseKay commented on Oct 6, 2026

    @SelaseKay
    Contributor

    Hi @ewankirk, thanks for this report. I'm looking into it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions