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)
- 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).
- 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.
Environment
firebase_database: 12.4.6,firebase_core: 4.12.1(Windows desktop)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:Expected behavior
Per the
runTransactioncontract (and the actual behavior on Android/iOS/web), a handler abort resolves normally withTransactionResult(committed: false).Actual behavior on Windows
The returned future throws a
FirebaseException:The intermediate Pigeon
PlatformExceptionhas codewrite-canceled, but the current FlutterFire exception converter falls back to Firebase codeunknownwhen Pigeondetailsis null.committed: falseis 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)
kErrorWriteCanceled(11) and no message — seeRepo::StartTransaction, which callsref_future->Complete(handle, kErrorWriteCanceled)when theDoTransactioncallback 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).committed: falseresult only when the native error iskErrorTransactionAbortedByUser(14); every other nonzero error is sent toParseError()and rejects the Pigeon call:flutterfire/packages/firebase_database/firebase_database/windows/firebase_database_plugin.cpp
Lines 745 to 779 in 007689f
GetDatabaseErrorCode()maps native 11 towrite-canceled, whileParseError()forwards the future's empty message:flutterfire/packages/firebase_database/firebase_database/windows/firebase_database_plugin.cpp
Lines 194 to 241 in 007689f
Because the desktop
StartTransactionpath 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 returningcommitted: false.Notes for a fix
PurgeOutstandingWrites()also cancels transactions withkErrorWriteCanceled(both unsent and sent paths), and those should remain errors — seeAbortTransactionsAtNode: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.
ParseError()usesfuture.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-
datastaleserver error (includingpermission_denied) is collapsed tokErrorUnknownErrorwith an empty message before it reaches this plugin, and a handler abort on a rerun invocation completes ascommitted: true.