Skip to content

Desktop RTDB: Repo::HandleTransactionResponse collapses every non-datastale server error (incl. permission_denied) to kErrorUnknownError with an empty message #1904

Description

@NH-Mason

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_database 12.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) — not kErrorPermissionDenied (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)

  1. The wire layer maps the response status correctly and retains the server message. HandlePutResponse() converts the status and passes the response body to TriggerResponse():
    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 includes permission_deniedkErrorPermissionDenied:
    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;
    }
  2. Repo::HandleTransactionResponse() then discards both fields for every error response other than datastale:
    for (auto& transaction : response->queue()) {
      transaction->status = TransactionData::kStatusNeedsAbort;
      transaction->abort_reason = kErrorUnknownError;
    }
    } 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());
    }
  3. RerunTransactionQueue() finally completes the future with the no-message overload, so error_message() is empty:
    DataSnapshot snapshot(new DataSnapshotInternal(
        database_, node, QuerySpec(transaction->path)));
    transaction->ref_future->CompleteWithResult(transaction->future_handle,
                                                abort_reason, snapshot);
    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(&current_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions