Skip to content

[firebase_database] Windows: a deliberate Transaction.abort() throws FirebaseException instead of returning TransactionResult(committed: false) #18549

Description

@NH-Mason

Environment

  • firebase_database: 12.4.6, firebase_core: 4.12.1 (Windows desktop)
  • Firebase C++ SDK resolved by the Windows CMake build: 13.9.0 (behavior identical at the 13.11.0 tag)
  • Flutter 3.47.0-0.1.pre (beta), Windows 11 Pro
  • Repro also matches the plugin source at the commit that introduced Windows support (007689f)

Steps to reproduce

In any FlutterFire Windows app that has already completed Firebase.initializeApp(...), run a transaction whose handler deliberately aborts on its first invocation:

final result = await FirebaseDatabase.instance
    .ref('some/path')
    .runTransaction((current) {
  return Transaction.abort(); // documented way to bail out
});
// expected: result.committed == false

Expected behavior

Per the runTransaction contract (and the actual behavior on Android/iOS/web), a handler abort resolves normally with TransactionResult(committed: false).

Actual behavior on Windows

The returned future throws a FirebaseException:

on FirebaseException catch (e) {
  print(e.code);    // unknown
  print(e.message); // '' (empty)
}

The intermediate Pigeon PlatformException has code write-canceled, but the current FlutterFire exception converter falls back to Firebase code unknown when Pigeon details is null. committed: false is therefore never observable for an initial-invocation deliberate abort, so state machines that use the documented abort contract (leader election, optimistic fences, or "another client holds the lock → follow") break on Windows.

Root cause (C++ SDK + plugin interaction, verified against pinned sources)

  1. The desktop C++ SDK completes a handler abort on the initial invocation with kErrorWriteCanceled (11) and no message — see Repo::StartTransaction, which calls ref_future->Complete(handle, kErrorWriteCanceled) when the DoTransaction callback does not return success:
    https://github.com/firebase/firebase-cpp-sdk/blob/3d7ce2a584d0b8daf1374bc2534c6ea71fa7fd6c/database/src/desktop/core/repo.cc#L659-L729
    The mobile SDKs report the same contract as kErrorTransactionAbortedByUser (14).
  2. The Windows transaction-completion branch treats a failed future as a normal committed: false result only when the native error is kErrorTransactionAbortedByUser (14); every other nonzero error is sent to ParseError() and rejects the Pigeon call:
    // Wait for the transaction to complete
    ref.RunTransactionLastResult().OnCompletion(
    [ctx](const Future<DataSnapshot>& future) {
    if (future.error() == Error::kErrorNone) {
    const DataSnapshot* snapshot = future.result();
    EncodableMap result_map;
    result_map[EncodableValue("committed")] = EncodableValue(true);
    if (snapshot) {
    result_map[EncodableValue("snapshot")] = EncodableValue(
    FirebaseDatabasePlugin::DataSnapshotToEncodableMap(*snapshot));
    } else {
    result_map[EncodableValue("snapshot")] = EncodableValue();
    }
    (*ctx->transaction_results)[ctx->transaction_key] = result_map;
    ctx->result(std::nullopt);
    } else {
    // Transaction failed but may have been aborted
    EncodableMap result_map;
    result_map[EncodableValue("committed")] = EncodableValue(false);
    result_map[EncodableValue("snapshot")] = EncodableValue(EncodableMap{
    {EncodableValue("key"), EncodableValue()},
    {EncodableValue("value"), EncodableValue()},
    {EncodableValue("priority"), EncodableValue()},
    {EncodableValue("childKeys"), EncodableValue(EncodableList{})},
    });
    (*ctx->transaction_results)[ctx->transaction_key] = result_map;
    if (static_cast<Error>(future.error()) ==
    Error::kErrorTransactionAbortedByUser) {
    // Aborted by user is not an error condition
    ctx->result(std::nullopt);
    } else {
    ctx->result(FirebaseDatabasePlugin::ParseError(future));
    }
    }

    GetDatabaseErrorCode() maps native 11 to write-canceled, while ParseError() forwards the future's empty message:
    // --- Helper: Error code string from C++ SDK Error enum ---
    std::string FirebaseDatabasePlugin::GetDatabaseErrorCode(Error error) {
    switch (error) {
    case Error::kErrorNone:
    return "none";
    case Error::kErrorDisconnected:
    return "disconnected";
    case Error::kErrorExpiredToken:
    return "expired-token";
    case Error::kErrorInvalidToken:
    return "invalid-token";
    case Error::kErrorMaxRetries:
    return "max-retries";
    case Error::kErrorNetworkError:
    return "network-error";
    case Error::kErrorOperationFailed:
    return "operation-failed";
    case Error::kErrorOverriddenBySet:
    return "overridden-by-set";
    case Error::kErrorPermissionDenied:
    return "permission-denied";
    case Error::kErrorUnavailable:
    return "unavailable";
    case Error::kErrorWriteCanceled:
    return "write-canceled";
    case Error::kErrorInvalidVariantType:
    return "invalid-variant-type";
    case Error::kErrorConflictingOperationInProgress:
    return "conflicting-operation-in-progress";
    case Error::kErrorTransactionAbortedByUser:
    return "transaction-aborted-by-user";
    default:
    return "unknown";
    }
    }
    std::string FirebaseDatabasePlugin::GetDatabaseErrorMessage(Error error) {
    const char* msg = firebase::database::GetErrorMessage(error);
    return msg ? std::string(msg) : "Unknown error";
    }
    FlutterError FirebaseDatabasePlugin::ParseError(
    const firebase::FutureBase& future) {
    Error error = static_cast<Error>(future.error());
    std::string code = GetDatabaseErrorCode(error);
    std::string message =
    future.error_message() ? future.error_message() : "Unknown error";
    return FlutterError(code, message);

Because the desktop StartTransaction path produces 11 rather than 14 when the handler aborts on its initial invocation, the plugin's 14-only benign branch does not apply on that path. A deliberate abort on the initial handler invocation therefore throws instead of returning committed: false.

Notes for a fix

  • Code 11 is overloaded on desktop: PurgeOutstandingWrites() also cancels transactions with kErrorWriteCanceled (both unsent and sent paths), and those should remain errors — see AbortTransactionsAtNode:
    https://github.com/firebase/firebase-cpp-sdk/blob/3d7ce2a584d0b8daf1374bc2534c6ea71fa7fd6c/database/src/desktop/core/repo.cc#L760-L858
    So mapping 11 → benign unconditionally is wrong. We fixed it in a vendored copy by recording "the Dart handler's reply deliberately aborted" on the plugin's transaction context and treating 11 as the abort contract only when that flag is set. Aligning the desktop SDK to complete deliberate aborts with 14 would also fix it at the source.
  • Secondary paper-cut in the same function: ParseError() uses future.error_message() ? ... : "Unknown error" — an empty C string is non-null, so desktop's message-less completions surface to Dart with an empty message, which makes this class of failure very hard to diagnose.

Related

Two adjacent desktop C++ SDK issues found in the same investigation (filed separately on firebase-cpp-sdk): every non-datastale server error (including permission_denied) is collapsed to kErrorUnknownError with an empty message before it reaches this plugin, and a handler abort on a rerun invocation completes as committed: true.

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