Repository navigation
Desktop RTDB: Repo::HandleTransactionResponse collapses every non-datastale server error (incl. permission_denied) to kErrorUnknownError with an empty message #1904
Description
Environment
- Firebase C++ SDK 13.9.0 (desktop/Windows; the cited code is unchanged at the 13.11.0 tag)
- Identified while tracing FlutterFire
firebase_database12.4.6 on Windows; the defect below is independently confirmed from the pinned desktop C++ source.
What happens
When the server rejects a transaction commit (e.g. a security-rules fence returns permission_denied on the wire), the transaction Future<DataSnapshot> completes with:
error()=kErrorUnknownError(10) — notkErrorPermissionDenied(8)error_message()=""(empty)
so callers cannot distinguish "rules denied this commit" from any other failure, and there is no message to log. Plain SetValue() writes on the same path correctly surface kErrorPermissionDenied, which makes the transaction behavior surprising.
Where the information is lost (pinned to 3d7ce2a, the 13.9.0 tag)
- The wire layer maps the response status correctly and retains the server message.
HandlePutResponse()converts the status and passes the response body toTriggerResponse():
firebase-cpp-sdk/database/src/desktop/connection/persistent_connection.cc
Lines 955 to 970 in 3d7ce2a
void PersistentConnection::HandlePutResponse(const Variant& message, const ResponsePtr& response, uint64_t outstanding_id) { auto it_put = outstanding_puts_.find(outstanding_id); if (it_put != outstanding_puts_.end()) { auto& put_ptr = it_put->second; logger_->LogDebug("%s %s response: %s", log_id_.c_str(), put_ptr->action.c_str(), util::VariantToJson(message).c_str()); std::string status_string = GetStringValue(message, kRequestStatus); Error error_code = StatusStringToErrorCode(status_string); bool is_ok = error_code == kErrorNone; TriggerResponse( response, error_code, is_ok ? "" : GetStringValue(message, kServerDataUpdateBody, true));
TriggerResponse()stores both values, and the adjacent status map includespermission_denied→kErrorPermissionDenied:
firebase-cpp-sdk/database/src/desktop/connection/persistent_connection.cc
Lines 1244 to 1281 in 3d7ce2a
void PersistentConnection::TriggerResponse(const ResponsePtr& response_ptr, Error error_code, const std::string& error_message) { if (response_ptr) { response_ptr->error_code_ = error_code; response_ptr->error_message_ = error_message; if (response_ptr->callback_) { response_ptr->callback_(response_ptr); } } } static const struct ErrorMap { const char* error_string; Error error_code; } g_error_codes[] = { {"ok", kErrorNone}, {"datastale", kErrorDataStale}, {"failure", kErrorOperationFailed}, {"permission_denied", kErrorPermissionDenied}, {"disconnected", kErrorDisconnected}, {"expired_token", kErrorExpiredToken}, {"invalid_token", kErrorInvalidToken}, {"maxretries", kErrorMaxRetries}, {"overriddenbyset", kErrorOverriddenBySet}, {"unavailable", kErrorUnavailable}, {"network_error", kErrorNetworkError}, {"write_canceled", kErrorWriteCanceled}, }; Error PersistentConnection::StatusStringToErrorCode(const std::string& status) { for (auto& error : g_error_codes) { if (status == error.error_string) { return error.error_code; } } return kErrorUnknownError; } Repo::HandleTransactionResponse()then discards both fields for every error response other thandatastale:for (auto& transaction : response->queue()) { transaction->status = TransactionData::kStatusNeedsAbort; transaction->abort_reason = kErrorUnknownError; }
firebase-cpp-sdk/database/src/desktop/core/repo.cc
Lines 982 to 1000 in 3d7ce2a
} else { // Transactions are no longer sent. Update their status appropriately. if (response->GetErrorCode() == kErrorDataStale) { for (auto& transaction : response->queue()) { if (transaction->status == TransactionData::kStatusSentNeedsAbort) { transaction->status = TransactionData::kStatusNeedsAbort; } else { transaction->status = TransactionData::kStatusRun; } } } else { for (auto& transaction : response->queue()) { transaction->status = TransactionData::kStatusNeedsAbort; transaction->abort_reason = kErrorUnknownError; } } RerunTransactions(response->path()); } RerunTransactionQueue()finally completes the future with the no-message overload, soerror_message()is empty:DataSnapshot snapshot(new DataSnapshotInternal( database_, node, QuerySpec(transaction->path))); transaction->ref_future->CompleteWithResult(transaction->future_handle, abort_reason, snapshot);
firebase-cpp-sdk/database/src/desktop/core/repo.cc
Lines 1003 to 1138 in 3d7ce2a
void Repo::RerunTransactionQueue(const std::vector<TransactionDataPtr>& queue, const Path& path) { logger_->LogDebug("RerunTransactionQueue @ %s (# of transaction : %d)", path.c_str(), static_cast<int>(queue.size())); if (queue.empty()) { // Nothing to do! return; } struct FutureToComplete { FutureToComplete(TransactionDataPtr transaction, Error abort_reason, Variant node) : transaction(transaction), abort_reason(abort_reason), node(node) {} TransactionDataPtr transaction; Error abort_reason; Variant node; }; std::vector<FutureToComplete> futures_to_complete; std::vector<WriteId> sets_to_ignore; sets_to_ignore.reserve(queue.size()); for (const TransactionDataPtr& transaction : queue) { sets_to_ignore.push_back(transaction->current_write_id); } for (const TransactionDataPtr& transaction : queue) { Optional<Path> relative_path = Path::GetRelative(path, transaction->path); assert(relative_path.has_value()); bool abort_transaction = false; Error abort_reason = kErrorNone; std::vector<Event> events; if (transaction->status == TransactionData::kStatusNeedsAbort) { abort_transaction = true; abort_reason = transaction->abort_reason; if (abort_reason != kErrorWriteCanceled) { Extend(&events, server_sync_tree_->AckUserWrite( transaction->current_write_id, kAckRevert, kDoNotPersist, server_time_offset_)); } } else if (transaction->status == TransactionData::kStatusRun) { if (transaction->retry_count >= TransactionData::kTransactionMaxRetries) { abort_transaction = true; abort_reason = kErrorMaxRetries; Extend(&events, server_sync_tree_->AckUserWrite( transaction->current_write_id, kAckRevert, kDoNotPersist, server_time_offset_)); } else { // This code rerun a transaction Variant current_input = GetLatestState(transaction->path, sets_to_ignore); // TODO(chkuang): Make sure the local cache does not contain vector. // Gently convert everything for now. if (HasVector(current_input)) { ConvertVectorToMap(¤t_input); } transaction->current_input_snapshot = current_input; MutableDataInternal* mutable_data_impl = new MutableDataInternal(database_, current_input); MutableData mutable_data(mutable_data_impl); Error error = kErrorNone; TransactionResult result = transaction->transaction_function( &mutable_data, transaction->context); if (result == kTransactionResultSuccess) { WriteId old_write_id = transaction->current_write_id; Variant server_values = GenerateServerValues(server_time_offset_); Variant* new_data_node = mutable_data_impl->GetNode(); Variant new_node_resolved = ResolveDeferredValueSnapshot(*new_data_node, server_values); transaction->current_output_snapshot_raw = *new_data_node; transaction->current_output_snapshot_resolved = new_node_resolved; transaction->current_write_id = GetNextWriteId(); sets_to_ignore.push_back(old_write_id); Extend(&events, server_sync_tree_->ApplyUserOverwrite( transaction->path, *new_data_node, new_node_resolved, transaction->current_write_id, transaction->trigger_local_events ? kOverwriteVisible : kOverwriteInvisible, kPersist)); Extend(&events, server_sync_tree_->AckUserWrite( old_write_id, kAckRevert, kDoNotPersist, server_time_offset_)); } else { abort_transaction = true; abort_reason = error; Extend(&events, server_sync_tree_->AckUserWrite( transaction->current_write_id, kAckRevert, kDoNotPersist, server_time_offset_)); } } } PostEvents(events); if (abort_transaction) { transaction->status = TransactionData::kStatusComplete; DatabaseReferenceInternal* database_ref_impl = new DatabaseReferenceInternal(database_, path); DatabaseReference ref(database_ref_impl); futures_to_complete.push_back(FutureToComplete( transaction, abort_reason, transaction->current_input_snapshot)); // Removing a callback can trigger pruning which can muck with // merged_data/visible_data (as it prunes data). So defer removing the // callback until later. Repo::scheduler().Schedule(NewCallback( [](Repo* repo, TransactionDataPtr transaction) { repo->RemoveEventCallback(transaction->outstanding_listener.get(), QuerySpec(transaction->path)); }, this, transaction)); } } PruneCompletedTransactions(&transaction_queue_tree_); for (auto& future_to_complete : futures_to_complete) { TransactionDataPtr& transaction = future_to_complete.transaction; Error& abort_reason = future_to_complete.abort_reason; Variant& node = future_to_complete.node; DataSnapshot snapshot(new DataSnapshotInternal( database_, node, QuerySpec(transaction->path))); transaction->ref_future->CompleteWithResult(transaction->future_handle, abort_reason, snapshot); } SendAllReadyTransactions(); }
Expected
abort_reason should carry the mapped response code (8 for permission_denied, etc.) and the completion should pass the response's error message through, matching what the Android SDK reports for the same server rejection.
Impact
Any desktop app implementing fenced/optimistic transactions (rules-enforced epochs, leases, counters) receives an undiagnosable unknown/empty failure for what is actually a well-defined rules denial. Downstream SDK wrappers (e.g. FlutterFire's Windows plugin) inherit the collapsed code, so the loss is user-visible in every binding built on this implementation.
Activity
NH-Mason commentedon Aug 8, 2026
Related: #1905 (rerun-invocation callback abort completes with kErrorNone) and firebase/flutterfire#18549 (the Windows plugin surface of the initial-invocation abort contract, where desktop error 11 vs mobile 14 breaks Transaction.abort()).