From 9dffa83d898c99ebca33a31ba29e618739f3d6d3 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Wed, 22 Jul 2026 00:18:01 +0530 Subject: [PATCH 1/7] feat(functions,windows): implement Cloud Functions for Windows desktop Implements the CloudFunctionsHostApi pigeon interface (already generated into windows/messages.g.*) over the Firebase C++ SDK, mirroring the Linux implementation from #18461: - httpsCallable / httpsCallableFromUrl calls with full Variant <-> EncodableValue conversion (including typed-data lists) - canonical error code mapping with code/message details for Dart's exception mapping - per-call timeout enforced by the plugin via a deadline thread, since the C++ SDK has no deadline API (responds deadline-exceeded; the SDK completion then only cleans up) - emulator origin support - streaming callables report 'unimplemented' (no C++ SDK support) Adds cloud_functions to the Windows e2e suite with skips limited to genuine C++ SDK gaps (streaming, error details payload) and a drain after the timeout test because the timed-out request cannot be cancelled. --- .../cloud_functions/pubspec.yaml | 2 + .../cloud_functions/windows/CMakeLists.txt | 80 +++ .../windows/cloud_functions_plugin.cpp | 375 +++++++++++++ .../windows/cloud_functions_plugin.h | 42 ++ .../windows/cloud_functions_plugin_c_api.cpp | 16 + .../cloud_functions_plugin_c_api.h | 27 + .../windows/plugin_version.h.in | 13 + .../cloud_functions_e2e_test.dart | 507 ++++++++++-------- tests/integration_test/e2e_test.dart | 1 + tests/integration_test/platform_utils.dart | 16 + 10 files changed, 843 insertions(+), 236 deletions(-) create mode 100644 packages/cloud_functions/cloud_functions/windows/CMakeLists.txt create mode 100644 packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp create mode 100644 packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.h create mode 100644 packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin_c_api.cpp create mode 100644 packages/cloud_functions/cloud_functions/windows/include/cloud_functions/cloud_functions_plugin_c_api.h create mode 100644 packages/cloud_functions/cloud_functions/windows/plugin_version.h.in create mode 100644 tests/integration_test/platform_utils.dart diff --git a/packages/cloud_functions/cloud_functions/pubspec.yaml b/packages/cloud_functions/cloud_functions/pubspec.yaml index 6b84a9a33e03..c630f16ac371 100644 --- a/packages/cloud_functions/cloud_functions/pubspec.yaml +++ b/packages/cloud_functions/cloud_functions/pubspec.yaml @@ -42,3 +42,5 @@ flutter: pluginClass: FirebaseFunctionsPlugin web: default_package: cloud_functions_web + windows: + pluginClass: CloudFunctionsPluginCApi diff --git a/packages/cloud_functions/cloud_functions/windows/CMakeLists.txt b/packages/cloud_functions/cloud_functions/windows/CMakeLists.txt new file mode 100644 index 000000000000..f435f47c6634 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/CMakeLists.txt @@ -0,0 +1,80 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "cloud_functions") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "cloud_functions_plugin") + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "cloud_functions_plugin.cpp" + "cloud_functions_plugin.h" + "messages.g.cpp" + "messages.g.h" +) + +# Read version from pubspec.yaml +file(STRINGS "../pubspec.yaml" pubspec_content) +foreach(line ${pubspec_content}) + string(FIND ${line} "version: " has_version) + + if("${has_version}" STREQUAL "0") + string(FIND ${line} ": " version_start_pos) + math(EXPR version_start_pos "${version_start_pos} + 2") + string(LENGTH ${line} version_end_pos) + math(EXPR len "${version_end_pos} - ${version_start_pos}") + string(SUBSTRING ${line} ${version_start_pos} ${len} PLUGIN_VERSION) + break() + endif() +endforeach(line) + +configure_file(plugin_version.h.in ${CMAKE_BINARY_DIR}/generated/cloud_functions/plugin_version.h) +include_directories(${CMAKE_BINARY_DIR}/generated/) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} STATIC + "include/cloud_functions/cloud_functions_plugin_c_api.h" + "cloud_functions_plugin_c_api.cpp" + ${PLUGIN_SOURCES} + ${CMAKE_BINARY_DIR}/generated/cloud_functions/plugin_version.h +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PUBLIC FLUTTER_PLUGIN_IMPL) +# Enable firebase-cpp-sdk's platform logging api. +target_compile_definitions(${PLUGIN_NAME} PRIVATE -DINTERNAL_EXPERIMENTAL=1) + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +set(MSVC_RUNTIME_MODE MD) +set(firebase_libs firebase_core_plugin firebase_functions) +target_link_libraries(${PLUGIN_NAME} PRIVATE "${firebase_libs}") + +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PUBLIC flutter flutter_wrapper_plugin) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(cloud_functions_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp new file mode 100644 index 000000000000..beb7f286d472 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp @@ -0,0 +1,375 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#include "cloud_functions_plugin.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "cloud_functions/plugin_version.h" +#include "firebase/app.h" +#include "firebase/functions.h" +#include "firebase/functions/callable_reference.h" +#include "firebase/functions/callable_result.h" +#include "firebase/functions/common.h" +#include "firebase/future.h" +#include "firebase/variant.h" +#include "messages.g.h" + +using ::firebase::App; +using ::firebase::Future; +using ::firebase::Variant; +using ::firebase::functions::Error; +using ::firebase::functions::Functions; +using ::firebase::functions::HttpsCallableReference; +using ::firebase::functions::HttpsCallableResult; + +namespace cloud_functions_windows { + +static std::string kLibraryName = "flutter-fire-fn"; + +// Cache of Functions instances keyed by "|". The C++ SDK +// manages their lifetime via App's CleanupNotifier, so these raw pointers are +// not owned here (mirroring the Linux implementation). +static std::map functions_instances_; + +// --- Helper: Convert firebase::Variant to EncodableValue --- +static flutter::EncodableValue VariantToEncodableValue(const Variant& variant) { + switch (variant.type()) { + case Variant::kTypeNull: + return flutter::EncodableValue(); + case Variant::kTypeInt64: + return flutter::EncodableValue(variant.int64_value()); + case Variant::kTypeDouble: + return flutter::EncodableValue(variant.double_value()); + case Variant::kTypeBool: + return flutter::EncodableValue(variant.bool_value()); + case Variant::kTypeStaticString: + return flutter::EncodableValue(std::string(variant.string_value())); + case Variant::kTypeMutableString: + return flutter::EncodableValue(variant.mutable_string()); + case Variant::kTypeVector: { + flutter::EncodableList list; + for (const auto& item : variant.vector()) { + list.push_back(VariantToEncodableValue(item)); + } + return flutter::EncodableValue(list); + } + case Variant::kTypeMap: { + flutter::EncodableMap map; + for (const auto& kv : variant.map()) { + map[VariantToEncodableValue(kv.first)] = + VariantToEncodableValue(kv.second); + } + return flutter::EncodableValue(map); + } + case Variant::kTypeStaticBlob: + case Variant::kTypeMutableBlob: { + const uint8_t* data = static_cast(variant.blob_data()); + return flutter::EncodableValue( + std::vector(data, data + variant.blob_size())); + } + default: + return flutter::EncodableValue(); + } +} + +// --- Helper: Convert EncodableValue to firebase::Variant --- +static Variant EncodableValueToVariant(const flutter::EncodableValue& value) { + if (std::holds_alternative(value)) { + return Variant::Null(); + } else if (std::holds_alternative(value)) { + return Variant(std::get(value)); + } else if (std::holds_alternative(value)) { + return Variant(static_cast(std::get(value))); + } else if (std::holds_alternative(value)) { + return Variant(std::get(value)); + } else if (std::holds_alternative(value)) { + return Variant(std::get(value)); + } else if (std::holds_alternative(value)) { + return Variant(std::get(value)); + } else if (std::holds_alternative>(value)) { + const auto& bytes = std::get>(value); + return Variant::FromMutableBlob(bytes.data(), bytes.size()); + } else if (std::holds_alternative>(value)) { + std::vector vec; + for (int32_t item : std::get>(value)) { + vec.push_back(Variant(static_cast(item))); + } + return Variant(vec); + } else if (std::holds_alternative>(value)) { + std::vector vec; + for (int64_t item : std::get>(value)) { + vec.push_back(Variant(item)); + } + return Variant(vec); + } else if (std::holds_alternative>(value)) { + std::vector vec; + for (float item : std::get>(value)) { + vec.push_back(Variant(static_cast(item))); + } + return Variant(vec); + } else if (std::holds_alternative>(value)) { + std::vector vec; + for (double item : std::get>(value)) { + vec.push_back(Variant(item)); + } + return Variant(vec); + } else if (std::holds_alternative(value)) { + std::vector vec; + for (const auto& item : std::get(value)) { + vec.push_back(EncodableValueToVariant(item)); + } + return Variant(vec); + } else if (std::holds_alternative(value)) { + std::map variant_map; + for (const auto& kv : std::get(value)) { + variant_map[EncodableValueToVariant(kv.first)] = + EncodableValueToVariant(kv.second); + } + return Variant(variant_map); + } + return Variant::Null(); +} + +// --- Helper: map a Cloud Functions error enum to its canonical string code --- +static std::string GetFunctionsErrorCode(Error error) { + switch (error) { + case Error::kErrorNone: + return "none"; + case Error::kErrorCancelled: + return "cancelled"; + case Error::kErrorInvalidArgument: + return "invalid-argument"; + case Error::kErrorDeadlineExceeded: + return "deadline-exceeded"; + case Error::kErrorNotFound: + return "not-found"; + case Error::kErrorAlreadyExists: + return "already-exists"; + case Error::kErrorPermissionDenied: + return "permission-denied"; + case Error::kErrorResourceExhausted: + return "resource-exhausted"; + case Error::kErrorFailedPrecondition: + return "failed-precondition"; + case Error::kErrorAborted: + return "aborted"; + case Error::kErrorOutOfRange: + return "out-of-range"; + case Error::kErrorUnimplemented: + return "unimplemented"; + case Error::kErrorInternal: + return "internal"; + case Error::kErrorUnavailable: + return "unavailable"; + case Error::kErrorDataLoss: + return "data-loss"; + case Error::kErrorUnauthenticated: + return "unauthenticated"; + case Error::kErrorUnknown: + default: + return "unknown"; + } +} + +// --- Helper: read a string entry from an EncodableMap (or empty) --- +static std::string GetStringArg(const flutter::EncodableMap& map, + const char* key) { + auto it = map.find(flutter::EncodableValue(key)); + if (it == map.end() || !std::holds_alternative(it->second)) { + return std::string(); + } + return std::get(it->second); +} + +// --- Helper: resolve (and cache) a Functions instance for app + region --- +static Functions* GetFunctionsInstance(const std::string& app_name, + const std::string& region) { + App* app = App::GetInstance(app_name.c_str()); + if (app == nullptr) { + return nullptr; + } + std::string cache_key = app_name + "|" + region; + + auto it = functions_instances_.find(cache_key); + if (it != functions_instances_.end()) { + return it->second; + } + + Functions* functions = region.empty() + ? Functions::GetInstance(app) + : Functions::GetInstance(app, region.c_str()); + if (functions == nullptr) { + return nullptr; + } + functions_instances_[cache_key] = functions; + return functions; +} + +static FlutterError MakeFunctionsError(const std::string& code, + const std::string& message) { + // Dart's platformExceptionToFirebaseFunctionsException reads the canonical + // code/message out of the error details map. + flutter::EncodableMap details; + details[flutter::EncodableValue("code")] = flutter::EncodableValue(code); + details[flutter::EncodableValue("message")] = + flutter::EncodableValue(message); + return FlutterError(code, message, flutter::EncodableValue(details)); +} + +// static +void CloudFunctionsPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto plugin = std::make_unique(); + + CloudFunctionsHostApi::SetUp(registrar->messenger(), plugin.get()); + + registrar->AddPlugin(std::move(plugin)); + + App::RegisterLibrary(kLibraryName.c_str(), getPluginVersion().c_str(), + nullptr); +} + +CloudFunctionsPlugin::CloudFunctionsPlugin() {} + +CloudFunctionsPlugin::~CloudFunctionsPlugin() = default; + +void CloudFunctionsPlugin::Call( + const flutter::EncodableMap& arguments, + std::function> reply)> + result) { + std::string app_name = GetStringArg(arguments, "appName"); + std::string region = GetStringArg(arguments, "region"); + std::string function_name = GetStringArg(arguments, "functionName"); + std::string function_uri = GetStringArg(arguments, "functionUri"); + std::string origin = GetStringArg(arguments, "origin"); + + if (app_name.empty()) { + result(MakeFunctionsError("invalid-argument", "Missing appName")); + return; + } + + Functions* functions = GetFunctionsInstance(app_name, region); + if (functions == nullptr) { + result(MakeFunctionsError("internal", "Functions instance not found")); + return; + } + + if (!origin.empty()) { + functions->UseFunctionsEmulator(origin.c_str()); + } + + // The HttpsCallableReference is heap-allocated and only deleted once the + // SDK completion has fired. The reference's internal owns the transport + // that runs the request on an SDK background thread; if the reference were + // a local and went out of scope when this handler returns, that transport + // would be destroyed mid-request and crash (use-after-free). + auto* ref = new HttpsCallableReference(); + if (!function_name.empty()) { + *ref = functions->GetHttpsCallable(function_name.c_str()); + } else if (!function_uri.empty()) { + *ref = functions->GetHttpsCallableFromURL(function_uri.c_str()); + } else { + delete ref; + result(MakeFunctionsError( + "invalid-argument", + "Either functionName or functionUri must be provided")); + return; + } + + Variant parameters = Variant::Null(); + auto parameters_it = arguments.find(flutter::EncodableValue("parameters")); + if (parameters_it != arguments.end()) { + parameters = EncodableValueToVariant(parameters_it->second); + } + + // The C++ SDK has no per-call deadline API, so the plugin enforces the + // Dart-provided timeout itself. Whichever of the deadline thread and the + // SDK completion runs first delivers the response; the loser only cleans + // up. Cleanup (reference delete) always happens in the completion path + // because deleting the reference while the request is in flight would + // destroy the transport mid-request. + struct CallState { + std::mutex mutex; + bool responded = false; + HttpsCallableReference* ref; + }; + auto state = std::make_shared(); + state->ref = ref; + + int64_t timeout_ms = 0; + auto timeout_it = arguments.find(flutter::EncodableValue("timeout")); + if (timeout_it != arguments.end()) { + if (std::holds_alternative(timeout_it->second)) { + timeout_ms = std::get(timeout_it->second); + } else if (std::holds_alternative(timeout_it->second)) { + timeout_ms = std::get(timeout_it->second); + } + } + if (timeout_ms > 0) { + std::thread([state, result, timeout_ms]() { + std::this_thread::sleep_for(std::chrono::milliseconds(timeout_ms)); + std::lock_guard lock(state->mutex); + if (!state->responded) { + state->responded = true; + result(MakeFunctionsError("deadline-exceeded", + "The operation timed out.")); + } + }).detach(); + } + + ref->Call(parameters) + .OnCompletion([state, result](const Future& future) { + if (future.error() == Error::kErrorNone) { + const HttpsCallableResult* callable_result = future.result(); + flutter::EncodableValue data = + callable_result != nullptr + ? VariantToEncodableValue(callable_result->data()) + : flutter::EncodableValue(); + { + std::lock_guard lock(state->mutex); + if (!state->responded) { + state->responded = true; + result(ErrorOr>( + std::optional(data))); + } + } + } else { + std::string code = + GetFunctionsErrorCode(static_cast(future.error())); + std::string message = + future.error_message() ? future.error_message() : "Unknown error"; + { + std::lock_guard lock(state->mutex); + if (!state->responded) { + state->responded = true; + result(MakeFunctionsError(code, message)); + } + } + } + delete state->ref; + state->ref = nullptr; + }); +} + +// Streaming callable functions (httpsCallable().stream()) are not supported +// by the Firebase C++ SDK. Fail explicitly rather than silently hanging. +void CloudFunctionsPlugin::RegisterEventChannel( + const flutter::EncodableMap& arguments, + std::function reply)> result) { + result(MakeFunctionsError( + "unimplemented", + "Streaming callable functions are not supported on Windows.")); +} + +} // namespace cloud_functions_windows diff --git a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.h b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.h new file mode 100644 index 000000000000..139daf3319d1 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.h @@ -0,0 +1,42 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#ifndef FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_H_ +#define FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_H_ + +#include +#include + +#include + +#include "messages.g.h" + +namespace cloud_functions_windows { + +class CloudFunctionsPlugin : public flutter::Plugin, + public CloudFunctionsHostApi { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + + CloudFunctionsPlugin(); + + virtual ~CloudFunctionsPlugin(); + + // Disallow copy and assign. + CloudFunctionsPlugin(const CloudFunctionsPlugin&) = delete; + CloudFunctionsPlugin& operator=(const CloudFunctionsPlugin&) = delete; + + // CloudFunctionsHostApi + void Call( + const flutter::EncodableMap& arguments, + std::function> reply)> + result) override; + void RegisterEventChannel( + const flutter::EncodableMap& arguments, + std::function reply)> result) override; +}; + +} // namespace cloud_functions_windows + +#endif // FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_H_ diff --git a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin_c_api.cpp b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin_c_api.cpp new file mode 100644 index 000000000000..6f3ddba63318 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin_c_api.cpp @@ -0,0 +1,16 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#include "include/cloud_functions/cloud_functions_plugin_c_api.h" + +#include + +#include "cloud_functions_plugin.h" + +void CloudFunctionsPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + cloud_functions_windows::CloudFunctionsPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/packages/cloud_functions/cloud_functions/windows/include/cloud_functions/cloud_functions_plugin_c_api.h b/packages/cloud_functions/cloud_functions/windows/include/cloud_functions/cloud_functions_plugin_c_api.h new file mode 100644 index 000000000000..e8c185de54f8 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/include/cloud_functions/cloud_functions_plugin_c_api.h @@ -0,0 +1,27 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#ifndef FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void CloudFunctionsPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_C_API_H_ diff --git a/packages/cloud_functions/cloud_functions/windows/plugin_version.h.in b/packages/cloud_functions/cloud_functions/windows/plugin_version.h.in new file mode 100644 index 000000000000..78aea8c9c299 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/plugin_version.h.in @@ -0,0 +1,13 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#ifndef PLUGIN_VERSION_CONFIG_H +#define PLUGIN_VERSION_CONFIG_H + +namespace cloud_functions_windows { + +std::string getPluginVersion() { return "@PLUGIN_VERSION@"; } +} // namespace cloud_functions_windows + +#endif // PLUGIN_VERSION_CONFIG_H diff --git a/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart b/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart index 66eed657b3b3..7d5bb1f445ab 100644 --- a/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart +++ b/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart @@ -12,6 +12,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:tests/firebase_options.dart'; +import '../platform_utils.dart'; import 'sample_data.dart' as data; String kTestFunctionDefaultRegion = 'testFunctionDefaultRegion'; @@ -191,27 +192,33 @@ void main() { } }); - test('it returns "details" value as part of the exception', () async { - try { - await callable({ - 'type': 'deepMap', - 'inputData': data.deepMap, - 'asError': true, - }); - fail('Should have thrown'); - } on FirebaseFunctionsException catch (e) { - expect(e.code, equals('cancelled')); - expect( - e.message, - equals( - 'Response data was requested to be sent as part of an Error payload, so here we are!', - ), - ); - expect(e.details, equals(data.deepMap)); - } catch (e) { - fail('$e'); - } - }); + test( + 'it returns "details" value as part of the exception', + () async { + try { + await callable({ + 'type': 'deepMap', + 'inputData': data.deepMap, + 'asError': true, + }); + fail('Should have thrown'); + } on FirebaseFunctionsException catch (e) { + expect(e.code, equals('cancelled')); + expect( + e.message, + equals( + 'Response data was requested to be sent as part of an Error payload, so here we are!', + ), + ); + expect(e.details, equals(data.deepMap)); + } catch (e) { + fail('$e'); + } + }, + // Desktop C++ SDK skip: exception `details` payloads are not + // propagated by the firebase-cpp-sdk. + skip: isDesktopCppSdk, + ); }); group('instanceFor', () { @@ -246,6 +253,12 @@ void main() { } catch (e) { fail('$e'); } + if (isDesktopCppSdk) { + // The C++ SDK cannot cancel the timed-out request; it keeps + // running on the transport and delays subsequent calls. Wait for + // it to drain so the next test is not queued behind it. + await Future.delayed(const Duration(seconds: 4)); + } }, // Android skip because it's flaky. See: // https://github.com/firebase/flutterfire/issues/9652 @@ -271,248 +284,270 @@ void main() { ); }); - group('HttpsCallable Stream', () { - test('returns a [StreamResponse]', () { - final streamResponseCallable = - FirebaseFunctions.instance.httpsCallable(kTestStreamResponse); - final stream = streamResponseCallable.stream(); - expect(stream, emits(isA())); - }); - - test('accepts a string value', () async { - final stream = callable.stream('foo').where((event) => event is Chunk); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('string')), - ), - ); - }); - - test('accepts a number value', () async { - final stream = callable - .stream(123) - .where((event) => event is Chunk) - .asBroadcastStream(); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('number')), - ), - ); - }); - - test('accepts no arguments', () async { - final stream = callable - .stream() - .where((event) => event is Chunk) - .asBroadcastStream(); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('null')), - ), - ); - }); + group( + 'HttpsCallable Stream', + () { + test('returns a [StreamResponse]', () { + final streamResponseCallable = + FirebaseFunctions.instance.httpsCallable(kTestStreamResponse); + final stream = streamResponseCallable.stream(); + expect(stream, emits(isA())); + }); - test('accepts a false boolean value', () async { - final stream = callable.stream(false).where((event) => event is Chunk); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('boolean')), - ), - ); - }); + test('accepts a string value', () async { + final stream = + callable.stream('foo').where((event) => event is Chunk); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals('string'), + ), + ), + ); + }); - test('accepts a true boolean value', () async { - final stream = callable.stream(true).where((event) => event is Chunk); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('boolean')), - ), - ); - }); + test('accepts a number value', () async { + final stream = callable + .stream(123) + .where((event) => event is Chunk) + .asBroadcastStream(); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals('number'), + ), + ), + ); + }); - test('can be called using an String url', () async { - final localhostMapped = - kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; + test('accepts no arguments', () async { + final stream = callable + .stream() + .where((event) => event is Chunk) + .asBroadcastStream(); + await expectLater( + stream, + emits( + isA() + .having((e) => e.partialData, 'partialData', equals('null')), + ), + ); + }); - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallableFromUrl( - 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', - ); + test('accepts a false boolean value', () async { + final stream = + callable.stream(false).where((event) => event is Chunk); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals('boolean'), + ), + ), + ); + }); - final stream = callable.stream(); - await expectLater(stream, emits(isA())); - }); + test('accepts a true boolean value', () async { + final stream = callable.stream(true).where((event) => event is Chunk); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals('boolean'), + ), + ), + ); + }); - test('can be called using an Uri url', () async { - final localhostMapped = - kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; + test('can be called using an String url', () async { + final localhostMapped = + kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallableFromUri( - Uri.parse( + HttpsCallable callable = + FirebaseFunctions.instance.httpsCallableFromUrl( 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', - ), - ); + ); - final stream = callable.stream(); - await expectLater(stream, emits(isA())); - }); + final stream = callable.stream(); + await expectLater(stream, emits(isA())); + }); - test( - 'concurrent streams on the same callable do not collide', - () async { - // Regression test for https://github.com/firebase/flutterfire/issues/18036 - final stream1 = callable - .stream('foo') - .where((event) => event is Chunk) - .map((event) => (event as Chunk).partialData) - .first; - final stream2 = callable - .stream(123) - .where((event) => event is Chunk) - .map((event) => (event as Chunk).partialData) - .first; + test('can be called using an Uri url', () async { + final localhostMapped = + kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; - final results = await Future.wait([stream1, stream2]); - expect(results[0], equals('string')); - expect(results[1], equals('number')); - }, - ); + HttpsCallable callable = + FirebaseFunctions.instance.httpsCallableFromUri( + Uri.parse( + 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', + ), + ); - test('should emit a [Result] as last value', () async { - final stream = await callable.stream().last; - expect( - stream, - isA(), + final stream = callable.stream(); + await expectLater(stream, emits(isA())); + }); + + test( + 'concurrent streams on the same callable do not collide', + () async { + // Regression test for https://github.com/firebase/flutterfire/issues/18036 + final stream1 = callable + .stream('foo') + .where((event) => event is Chunk) + .map((event) => (event as Chunk).partialData) + .first; + final stream2 = callable + .stream(123) + .where((event) => event is Chunk) + .map((event) => (event as Chunk).partialData) + .first; + + final results = await Future.wait([stream1, stream2]); + expect(results[0], equals('string')); + expect(results[1], equals('number')); + }, ); - }); - test( - 'Result.data is Map for object-shaped JSON', - () async { - final stream = callable.stream({ - 'type': 'deepMap', - 'inputData': data.deepMap, - }); - final terminalEvent = await stream.where((e) => e is Result).last; - expect(terminalEvent, isA()); - final result = (terminalEvent as Result).result; + test('should emit a [Result] as last value', () async { + final stream = await callable.stream().last; expect( - result.data, - isA>(), + stream, + isA(), ); - }, - skip: !kIsWeb, - ); + }); - test('accepts a [List]', () async { - final stream = - callable.stream(data.list).where((event) => event is Chunk); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('array')), - ), + test( + 'Result.data is Map for object-shaped JSON', + () async { + final stream = callable.stream({ + 'type': 'deepMap', + 'inputData': data.deepMap, + }); + final terminalEvent = await stream.where((e) => e is Result).last; + expect(terminalEvent, isA()); + final result = (terminalEvent as Result).result; + expect( + result.data, + isA>(), + ); + }, + skip: !kIsWeb, ); - }); - test('accepts a deeply nested [Map]', () async { - final stream = callable.stream({ - 'type': 'deepMap', - 'inputData': data.deepMap, - }).where((event) => event is Chunk); - await expectLater( - stream, - emits( - isA().having( - (e) => e.partialData, - 'partialData', - equals(data.deepMap), + test('accepts a [List]', () async { + final stream = + callable.stream(data.list).where((event) => event is Chunk); + await expectLater( + stream, + emits( + isA() + .having((e) => e.partialData, 'partialData', equals('array')), ), - ), - ); - }); - - test( - 'throws error when aborted with TimeLimit signal', - () async { - final instance = FirebaseFunctions.instance; - instance.useFunctionsEmulator('localhost', 5001); - - final completer = Completer(); + ); + }); - final timeoutCallable = FirebaseFunctions.instance.httpsCallable( - kTestFunctionTimeout, - options: HttpsCallableOptions( - webAbortSignal: TimeLimit(const Duration(seconds: 3)), + test('accepts a deeply nested [Map]', () async { + final stream = callable.stream({ + 'type': 'deepMap', + 'inputData': data.deepMap, + }).where((event) => event is Chunk); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals(data.deepMap), + ), ), ); + }); - timeoutCallable.stream({ - 'testTimeout': const Duration(seconds: 6).inMilliseconds.toString(), - }).listen( - (data) { - completer.completeError('Should have thrown'); - }, - onError: (error) { - if (error is FirebaseFunctionsException) { - expect(error.code, equals('internal')); - completer.complete(); - } else { - completer.completeError('Unexpected error type: $error'); - } - }, - ); - await completer.future; - }, - skip: !kIsWeb, - ); + test( + 'throws error when aborted with TimeLimit signal', + () async { + final instance = FirebaseFunctions.instance; + instance.useFunctionsEmulator('localhost', 5001); - test( - 'throws error when aborted with Abort signal', - () async { - final instance = FirebaseFunctions.instance; - instance.useFunctionsEmulator('localhost', 5001); + final completer = Completer(); - final completer = Completer(); + final timeoutCallable = FirebaseFunctions.instance.httpsCallable( + kTestFunctionTimeout, + options: HttpsCallableOptions( + webAbortSignal: TimeLimit(const Duration(seconds: 3)), + ), + ); - final timeoutCallable = FirebaseFunctions.instance.httpsCallable( - kTestFunctionTimeout, - options: HttpsCallableOptions( - webAbortSignal: Abort('aborted'), - ), - ); + timeoutCallable.stream({ + 'testTimeout': + const Duration(seconds: 6).inMilliseconds.toString(), + }).listen( + (data) { + completer.completeError('Should have thrown'); + }, + onError: (error) { + if (error is FirebaseFunctionsException) { + expect(error.code, equals('internal')); + completer.complete(); + } else { + completer.completeError('Unexpected error type: $error'); + } + }, + ); + await completer.future; + }, + skip: !kIsWeb, + ); - timeoutCallable.stream({ - 'testTimeout': const Duration(seconds: 6).inMilliseconds.toString(), - }).listen( - (data) { - completer.completeError('Should have thrown'); - }, - onError: (error) { - if (error is FirebaseFunctionsException) { - expect(error.code, equals('internal')); - completer.complete(); - } else { - completer.completeError('Unexpected error type: $error'); - } - }, - ); - await completer.future; - }, - skip: !kIsWeb, - ); - }); + test( + 'throws error when aborted with Abort signal', + () async { + final instance = FirebaseFunctions.instance; + instance.useFunctionsEmulator('localhost', 5001); + + final completer = Completer(); + + final timeoutCallable = FirebaseFunctions.instance.httpsCallable( + kTestFunctionTimeout, + options: HttpsCallableOptions( + webAbortSignal: Abort('aborted'), + ), + ); + + timeoutCallable.stream({ + 'testTimeout': + const Duration(seconds: 6).inMilliseconds.toString(), + }).listen( + (data) { + completer.completeError('Should have thrown'); + }, + onError: (error) { + if (error is FirebaseFunctionsException) { + expect(error.code, equals('internal')); + completer.complete(); + } else { + completer.completeError('Unexpected error type: $error'); + } + }, + ); + await completer.future; + }, + skip: !kIsWeb, + ); + }, + // Desktop C++ SDK skip: streaming callables are not supported by the + // firebase-cpp-sdk. + skip: isDesktopCppSdk, + ); }); } diff --git a/tests/integration_test/e2e_test.dart b/tests/integration_test/e2e_test.dart index e4ef09e84009..f0fba6328138 100644 --- a/tests/integration_test/e2e_test.dart +++ b/tests/integration_test/e2e_test.dart @@ -79,6 +79,7 @@ void main() { case TargetPlatform.windows: firebase_core.main(); firebase_auth.main(); + cloud_functions.main(); firebase_remote_config.main(); firebase_storage.main(); firebase_app_check.main(); diff --git a/tests/integration_test/platform_utils.dart b/tests/integration_test/platform_utils.dart new file mode 100644 index 000000000000..a57794a5311b --- /dev/null +++ b/tests/integration_test/platform_utils.dart @@ -0,0 +1,16 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +/// Whether the tests are running against the desktop implementations backed by +/// the Firebase C++ SDK (Windows and Linux). Both platforms share the same +/// C++ SDK, so they have identical feature limitations in the e2e suites. +/// +/// The `!kIsWeb` check matters because web e2e tests run on Linux CI runners, +/// where `defaultTargetPlatform` reports [TargetPlatform.linux]. +bool get isDesktopCppSdk => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.linux); From 4bb1b36d937e0ee4707c8c72a7ab47836370abb6 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Wed, 22 Jul 2026 00:46:26 +0530 Subject: [PATCH 2/7] refactor(functions,windows): drop local Functions instance cache The C++ SDK already caches instances per (app, region) inside Functions::GetInstance; a plugin-side raw-pointer cache could dangle if an app were deleted and recreated. Addresses review feedback. --- .../windows/cloud_functions_plugin.cpp | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp index beb7f286d472..7acbf77239ce 100644 --- a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp +++ b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp @@ -37,11 +37,6 @@ namespace cloud_functions_windows { static std::string kLibraryName = "flutter-fire-fn"; -// Cache of Functions instances keyed by "|". The C++ SDK -// manages their lifetime via App's CleanupNotifier, so these raw pointers are -// not owned here (mirroring the Linux implementation). -static std::map functions_instances_; - // --- Helper: Convert firebase::Variant to EncodableValue --- static flutter::EncodableValue VariantToEncodableValue(const Variant& variant) { switch (variant.type()) { @@ -192,28 +187,18 @@ static std::string GetStringArg(const flutter::EncodableMap& map, return std::get(it->second); } -// --- Helper: resolve (and cache) a Functions instance for app + region --- +// --- Helper: resolve a Functions instance for app + region --- +// The C++ SDK caches instances internally per (app, region), so no local +// cache is kept; a local raw-pointer cache would dangle if an app were +// deleted and recreated. static Functions* GetFunctionsInstance(const std::string& app_name, const std::string& region) { App* app = App::GetInstance(app_name.c_str()); if (app == nullptr) { return nullptr; } - std::string cache_key = app_name + "|" + region; - - auto it = functions_instances_.find(cache_key); - if (it != functions_instances_.end()) { - return it->second; - } - - Functions* functions = region.empty() - ? Functions::GetInstance(app) - : Functions::GetInstance(app, region.c_str()); - if (functions == nullptr) { - return nullptr; - } - functions_instances_[cache_key] = functions; - return functions; + return region.empty() ? Functions::GetInstance(app) + : Functions::GetInstance(app, region.c_str()); } static FlutterError MakeFunctionsError(const std::string& code, From 513838b25b09eb7ee935b19711ddea9853f25800 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Wed, 22 Jul 2026 01:00:33 +0530 Subject: [PATCH 3/7] docs: mark Cloud Functions as supported (dev-only) on Windows --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ea77c522637c..c47cd3aec7fa 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ and open source. | App Check | [![App Check pub.dev badge](https://img.shields.io/pub/v/firebase_app_check.svg)](https://pub.dev/packages/firebase_app_check) | [🔗](https://firebase.google.com/docs/app-check) | [📖](https://firebase.google.com/docs/app-check/flutter/default-providers) | [`firebase_app_check`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/firebase_app_check) | ✔ | ✔ | ✔ | β | N/A | | Authentication | [![Authentication pub.dev badge](https://img.shields.io/pub/v/firebase_auth.svg)](https://pub.dev/packages/firebase_auth) | [🔗](https://firebase.google.com/products/auth) | [📖](https://firebase.google.com/docs/auth/flutter/start) | [`firebase_auth`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/firebase_auth) | ✔ | ✔ | ✔ | β | (*) | | Cloud Firestore | [![Cloud Firestore pub.dev badge](https://img.shields.io/pub/v/cloud_firestore.svg)](https://pub.dev/packages/cloud_firestore) | [🔗](https://firebase.google.com/products/firestore) | [📖](https://firebase.google.com/docs/firestore/quickstart) | [`cloud_firestore`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/cloud_firestore) | ✔ | ✔ | ✔ | β | (*) | -| Cloud Functions | [![Cloud Functions pub.dev badge](https://img.shields.io/pub/v/cloud_functions.svg)](https://pub.dev/packages/cloud_functions) | [🔗](https://firebase.google.com/products/functions) | [📖](https://firebase.google.com/docs/functions/get-started?gen=2nd) | [`cloud_functions`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/cloud_functions) | ✔ | ✔ | ✔ | β | N/A | +| Cloud Functions | [![Cloud Functions pub.dev badge](https://img.shields.io/pub/v/cloud_functions.svg)](https://pub.dev/packages/cloud_functions) | [🔗](https://firebase.google.com/products/functions) | [📖](https://firebase.google.com/docs/functions/get-started?gen=2nd) | [`cloud_functions`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/cloud_functions) | ✔ | ✔ | ✔ | β | (*) | | Cloud Messaging | [![Cloud Messaging pub.dev badge](https://img.shields.io/pub/v/firebase_messaging.svg)](https://pub.dev/packages/firebase_messaging) | [🔗](https://firebase.google.com/products/cloud-messaging) | [📖](https://firebase.google.com/docs/cloud-messaging/flutter/client) | [`firebase_messaging`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/firebase_messaging) | ✔ | ✔ | ✔ | β | N/A | | Cloud Storage | [![Cloud Storage pub.dev badge](https://img.shields.io/pub/v/firebase_storage.svg)](https://pub.dev/packages/firebase_storage) | [🔗](https://firebase.google.com/products/storage) | [📖](https://firebase.google.com/docs/storage/flutter/start) | [`firebase_storage`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/firebase_storage) | ✔ | ✔ | ✔ | β | (*) | | Core | [![Core pub.dev badge](https://img.shields.io/pub/v/firebase_core.svg)](https://pub.dev/packages/firebase_core) | [🔗](https://firebase.google.com) | [📖](https://firebase.google.com) | [`firebase_core`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/firebase_core) | ✔ | ✔ | ✔ | β | (*) | From 79079cc275eb75da427fc04916cfbb8b9cca33e4 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Wed, 22 Jul 2026 01:05:06 +0530 Subject: [PATCH 4/7] perf(functions,windows): wake the deadline thread early on completion Dart sends a 70s default timeout, so every call parked its deadline thread (holding the reply callback) for the full duration. A condition variable notified from the completion handler lets the thread exit as soon as the request finishes. Addresses review feedback. --- .../windows/cloud_functions_plugin.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp index 7acbf77239ce..40ac540f89be 100644 --- a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp +++ b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -286,6 +287,7 @@ void CloudFunctionsPlugin::Call( // destroy the transport mid-request. struct CallState { std::mutex mutex; + std::condition_variable cv; bool responded = false; HttpsCallableReference* ref; }; @@ -303,9 +305,12 @@ void CloudFunctionsPlugin::Call( } if (timeout_ms > 0) { std::thread([state, result, timeout_ms]() { - std::this_thread::sleep_for(std::chrono::milliseconds(timeout_ms)); - std::lock_guard lock(state->mutex); - if (!state->responded) { + std::unique_lock lock(state->mutex); + // wait_for returns false only on timeout; a completed request notifies + // the condition variable so this thread exits (and releases its + // captures) immediately instead of sleeping out the full deadline. + if (!state->cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), + [&state] { return state->responded; })) { state->responded = true; result(MakeFunctionsError("deadline-exceeded", "The operation timed out.")); @@ -329,6 +334,7 @@ void CloudFunctionsPlugin::Call( std::optional(data))); } } + state->cv.notify_all(); } else { std::string code = GetFunctionsErrorCode(static_cast(future.error())); @@ -341,6 +347,7 @@ void CloudFunctionsPlugin::Call( result(MakeFunctionsError(code, message)); } } + state->cv.notify_all(); } delete state->ref; state->ref = nullptr; From 2062242cc883ce08dff6127381188e4d64dd8298 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Wed, 22 Jul 2026 01:11:27 +0530 Subject: [PATCH 5/7] fix(functions,windows): invoke reply callbacks outside the state mutex; guard zero-size blobs --- .../windows/cloud_functions_plugin.cpp | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp index 40ac540f89be..5888a95b102a 100644 --- a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp +++ b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp @@ -71,6 +71,9 @@ static flutter::EncodableValue VariantToEncodableValue(const Variant& variant) { case Variant::kTypeStaticBlob: case Variant::kTypeMutableBlob: { const uint8_t* data = static_cast(variant.blob_data()); + if (data == nullptr || variant.blob_size() == 0) { + return flutter::EncodableValue(std::vector()); + } return flutter::EncodableValue( std::vector(data, data + variant.blob_size())); } @@ -312,6 +315,7 @@ void CloudFunctionsPlugin::Call( if (!state->cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), [&state] { return state->responded; })) { state->responded = true; + lock.unlock(); result(MakeFunctionsError("deadline-exceeded", "The operation timed out.")); } @@ -326,28 +330,36 @@ void CloudFunctionsPlugin::Call( callable_result != nullptr ? VariantToEncodableValue(callable_result->data()) : flutter::EncodableValue(); + bool deliver = false; { std::lock_guard lock(state->mutex); if (!state->responded) { state->responded = true; - result(ErrorOr>( - std::optional(data))); + deliver = true; } } state->cv.notify_all(); + if (deliver) { + result(ErrorOr>( + std::optional(data))); + } } else { std::string code = GetFunctionsErrorCode(static_cast(future.error())); std::string message = future.error_message() ? future.error_message() : "Unknown error"; + bool deliver = false; { std::lock_guard lock(state->mutex); if (!state->responded) { state->responded = true; - result(MakeFunctionsError(code, message)); + deliver = true; } } state->cv.notify_all(); + if (deliver) { + result(MakeFunctionsError(code, message)); + } } delete state->ref; state->ref = nullptr; From 5c7adae7fce966c6f8743c3fb24f8968160acba5 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Thu, 23 Jul 2026 06:03:21 +0530 Subject: [PATCH 6/7] fix(core,windows): add firebase_functions to FIREBASE_RELEASE_PATH_LIBS --- packages/firebase_core/firebase_core/windows/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/firebase_core/firebase_core/windows/CMakeLists.txt b/packages/firebase_core/firebase_core/windows/CMakeLists.txt index 3d856893cd65..522c7273f1fa 100644 --- a/packages/firebase_core/firebase_core/windows/CMakeLists.txt +++ b/packages/firebase_core/firebase_core/windows/CMakeLists.txt @@ -122,7 +122,7 @@ add_subdirectory(${FIREBASE_CPP_SDK_DIR} bin/ EXCLUDE_FROM_ALL) target_include_directories(${PLUGIN_NAME} INTERFACE "${FIREBASE_CPP_SDK_DIR}/include") -set(FIREBASE_RELEASE_PATH_LIBS firebase_app firebase_auth firebase_remote_config firebase_storage firebase_firestore firebase_database firebase_app_check) +set(FIREBASE_RELEASE_PATH_LIBS firebase_app firebase_auth firebase_remote_config firebase_storage firebase_firestore firebase_database firebase_app_check firebase_functions) foreach(firebase_lib IN ITEMS ${FIREBASE_RELEASE_PATH_LIBS}) get_target_property(firebase_lib_path ${firebase_lib} IMPORTED_LOCATION) string(REPLACE "Debug" "Release" firebase_lib_release_path ${firebase_lib_path}) From 36bc7e0458eb541c80a4ce00d97205055519fef8 Mon Sep 17 00:00:00 2001 From: Akshay CM Date: Fri, 24 Jul 2026 07:25:33 +0530 Subject: [PATCH 7/7] fix(ci,windows): build functions before running emulator and format Dart tests --- .github/workflows/windows.yaml | 2 + .../cloud_functions_e2e_test.dart | 326 +++++++++--------- 2 files changed, 166 insertions(+), 162 deletions(-) diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index 4aab47f6c032..6442264221b0 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -61,6 +61,7 @@ jobs: - name: "Install Tools" run: | npm install -g firebase-tools + cd .github/workflows/scripts/functions && npm install - name: "Build Windows (Release)" run: cd tests && flutter build windows --release - name: Start Firebase Emulator and run tests @@ -95,6 +96,7 @@ jobs: - name: "Install Tools" run: | npm install -g firebase-tools + cd .github/workflows/scripts/functions && npm install - name: Start Firebase Emulator and run tests run: | cd ./.github/workflows/scripts diff --git a/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart b/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart index 7d5bb1f445ab..f10298aadee4 100644 --- a/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart +++ b/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart @@ -32,8 +32,9 @@ void main() { options: DefaultFirebaseOptions.currentPlatform, ); FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001); - callable = - FirebaseFunctions.instance.httpsCallable(kTestFunctionDefaultRegion); + callable = FirebaseFunctions.instance.httpsCallable( + kTestFunctionDefaultRegion, + ); }); group('HttpsCallable', () { @@ -139,8 +140,9 @@ void main() { test( '[HttpsCallableResult.data] should return Map type for returned objects', () async { - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallable(kTestMapConvertType); + HttpsCallable callable = FirebaseFunctions.instance.httpsCallable( + kTestMapConvertType, + ); var result = await callable(); @@ -149,11 +151,12 @@ void main() { ); test('can be called using an String url', () async { - final localhostMapped = - kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; + final localhostMapped = kIsWeb || !Platform.isAndroid + ? 'localhost' + : '10.0.2.2'; - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallableFromUrl( + HttpsCallable + callable = FirebaseFunctions.instance.httpsCallableFromUrl( 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', ); @@ -162,11 +165,12 @@ void main() { }); test('can be called using an Uri url', () async { - final localhostMapped = - kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; + final localhostMapped = kIsWeb || !Platform.isAndroid + ? 'localhost' + : '10.0.2.2'; - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallableFromUri( + HttpsCallable + callable = FirebaseFunctions.instance.httpsCallableFromUri( Uri.parse( 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', ), @@ -178,19 +182,21 @@ void main() { }); group('FirebaseFunctionsException', () { - test('HttpsCallable returns a FirebaseFunctionsException on error', - () async { - try { - await callable({}); - fail('Should have thrown'); - } on FirebaseFunctionsException catch (e) { - expect(e.code, equals('invalid-argument')); - expect(e.message, equals('Invalid test requested.')); - return; - } catch (e) { - fail('$e'); - } - }); + test( + 'HttpsCallable returns a FirebaseFunctionsException on error', + () async { + try { + await callable({}); + fail('Should have thrown'); + } on FirebaseFunctionsException catch (e) { + expect(e.code, equals('invalid-argument')); + expect(e.message, equals('Invalid test requested.')); + return; + } catch (e) { + fail('$e'); + } + }, + ); test( 'it returns "details" value as part of the exception', @@ -225,8 +231,9 @@ void main() { test('accepts a custom region', () async { final instance = FirebaseFunctions.instanceFor(region: 'europe-west1'); instance.useFunctionsEmulator('localhost', 5001); - final customRegionCallable = - instance.httpsCallable(kTestFunctionCustomRegion); + final customRegionCallable = instance.httpsCallable( + kTestFunctionCustomRegion, + ); final result = await customRegionCallable(); expect(result.data, equals('europe-west1')); }); @@ -244,8 +251,9 @@ void main() { ); try { await timeoutCallable({ - 'testTimeout': - const Duration(seconds: 6).inMilliseconds.toString(), + 'testTimeout': const Duration( + seconds: 6, + ).inMilliseconds.toString(), }); fail('Should have thrown'); } on FirebaseFunctionsException catch (e) { @@ -265,38 +273,36 @@ void main() { skip: defaultTargetPlatform == TargetPlatform.android, ); - test( - 'allow passing of `limitedUseAppCheckToken` as option', - () async { - final instance = FirebaseFunctions.instance; - instance.useFunctionsEmulator('localhost', 5001); - final timeoutCallable = FirebaseFunctions.instance.httpsCallable( - kTestFunctionDefaultRegion, - options: HttpsCallableOptions( - timeout: const Duration(seconds: 3), - limitedUseAppCheckToken: true, - ), - ); + test('allow passing of `limitedUseAppCheckToken` as option', () async { + final instance = FirebaseFunctions.instance; + instance.useFunctionsEmulator('localhost', 5001); + final timeoutCallable = FirebaseFunctions.instance.httpsCallable( + kTestFunctionDefaultRegion, + options: HttpsCallableOptions( + timeout: const Duration(seconds: 3), + limitedUseAppCheckToken: true, + ), + ); - HttpsCallableResult results = await timeoutCallable(); - expect(results.data, equals('null')); - }, - ); + HttpsCallableResult results = await timeoutCallable(); + expect(results.data, equals('null')); + }); }); group( 'HttpsCallable Stream', () { test('returns a [StreamResponse]', () { - final streamResponseCallable = - FirebaseFunctions.instance.httpsCallable(kTestStreamResponse); + final streamResponseCallable = FirebaseFunctions.instance + .httpsCallable(kTestStreamResponse); final stream = streamResponseCallable.stream(); expect(stream, emits(isA())); }); test('accepts a string value', () async { - final stream = - callable.stream('foo').where((event) => event is Chunk); + final stream = callable + .stream('foo') + .where((event) => event is Chunk); await expectLater( stream, emits( @@ -334,15 +340,19 @@ void main() { await expectLater( stream, emits( - isA() - .having((e) => e.partialData, 'partialData', equals('null')), + isA().having( + (e) => e.partialData, + 'partialData', + equals('null'), + ), ), ); }); test('accepts a false boolean value', () async { - final stream = - callable.stream(false).where((event) => event is Chunk); + final stream = callable + .stream(false) + .where((event) => event is Chunk); await expectLater( stream, emits( @@ -370,11 +380,12 @@ void main() { }); test('can be called using an String url', () async { - final localhostMapped = - kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; + final localhostMapped = kIsWeb || !Platform.isAndroid + ? 'localhost' + : '10.0.2.2'; - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallableFromUrl( + HttpsCallable + callable = FirebaseFunctions.instance.httpsCallableFromUrl( 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', ); @@ -383,11 +394,12 @@ void main() { }); test('can be called using an Uri url', () async { - final localhostMapped = - kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; + final localhostMapped = kIsWeb || !Platform.isAndroid + ? 'localhost' + : '10.0.2.2'; - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallableFromUri( + HttpsCallable + callable = FirebaseFunctions.instance.httpsCallableFromUri( Uri.parse( 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', ), @@ -397,33 +409,27 @@ void main() { await expectLater(stream, emits(isA())); }); - test( - 'concurrent streams on the same callable do not collide', - () async { - // Regression test for https://github.com/firebase/flutterfire/issues/18036 - final stream1 = callable - .stream('foo') - .where((event) => event is Chunk) - .map((event) => (event as Chunk).partialData) - .first; - final stream2 = callable - .stream(123) - .where((event) => event is Chunk) - .map((event) => (event as Chunk).partialData) - .first; - - final results = await Future.wait([stream1, stream2]); - expect(results[0], equals('string')); - expect(results[1], equals('number')); - }, - ); + test('concurrent streams on the same callable do not collide', () async { + // Regression test for https://github.com/firebase/flutterfire/issues/18036 + final stream1 = callable + .stream('foo') + .where((event) => event is Chunk) + .map((event) => (event as Chunk).partialData) + .first; + final stream2 = callable + .stream(123) + .where((event) => event is Chunk) + .map((event) => (event as Chunk).partialData) + .first; + + final results = await Future.wait([stream1, stream2]); + expect(results[0], equals('string')); + expect(results[1], equals('number')); + }); test('should emit a [Result] as last value', () async { final stream = await callable.stream().last; - expect( - stream, - isA(), - ); + expect(stream, isA()); }); test( @@ -436,31 +442,31 @@ void main() { final terminalEvent = await stream.where((e) => e is Result).last; expect(terminalEvent, isA()); final result = (terminalEvent as Result).result; - expect( - result.data, - isA>(), - ); + expect(result.data, isA>()); }, skip: !kIsWeb, ); test('accepts a [List]', () async { - final stream = - callable.stream(data.list).where((event) => event is Chunk); + final stream = callable + .stream(data.list) + .where((event) => event is Chunk); await expectLater( stream, emits( - isA() - .having((e) => e.partialData, 'partialData', equals('array')), + isA().having( + (e) => e.partialData, + 'partialData', + equals('array'), + ), ), ); }); test('accepts a deeply nested [Map]', () async { - final stream = callable.stream({ - 'type': 'deepMap', - 'inputData': data.deepMap, - }).where((event) => event is Chunk); + final stream = callable + .stream({'type': 'deepMap', 'inputData': data.deepMap}) + .where((event) => event is Chunk); await expectLater( stream, emits( @@ -473,77 +479,73 @@ void main() { ); }); - test( - 'throws error when aborted with TimeLimit signal', - () async { - final instance = FirebaseFunctions.instance; - instance.useFunctionsEmulator('localhost', 5001); - - final completer = Completer(); + test('throws error when aborted with TimeLimit signal', () async { + final instance = FirebaseFunctions.instance; + instance.useFunctionsEmulator('localhost', 5001); - final timeoutCallable = FirebaseFunctions.instance.httpsCallable( - kTestFunctionTimeout, - options: HttpsCallableOptions( - webAbortSignal: TimeLimit(const Duration(seconds: 3)), - ), - ); + final completer = Completer(); - timeoutCallable.stream({ - 'testTimeout': - const Duration(seconds: 6).inMilliseconds.toString(), - }).listen( - (data) { - completer.completeError('Should have thrown'); - }, - onError: (error) { - if (error is FirebaseFunctionsException) { - expect(error.code, equals('internal')); - completer.complete(); - } else { - completer.completeError('Unexpected error type: $error'); - } - }, - ); - await completer.future; - }, - skip: !kIsWeb, - ); + final timeoutCallable = FirebaseFunctions.instance.httpsCallable( + kTestFunctionTimeout, + options: HttpsCallableOptions( + webAbortSignal: TimeLimit(const Duration(seconds: 3)), + ), + ); - test( - 'throws error when aborted with Abort signal', - () async { - final instance = FirebaseFunctions.instance; - instance.useFunctionsEmulator('localhost', 5001); + timeoutCallable + .stream({ + 'testTimeout': const Duration( + seconds: 6, + ).inMilliseconds.toString(), + }) + .listen( + (data) { + completer.completeError('Should have thrown'); + }, + onError: (error) { + if (error is FirebaseFunctionsException) { + expect(error.code, equals('internal')); + completer.complete(); + } else { + completer.completeError('Unexpected error type: $error'); + } + }, + ); + await completer.future; + }, skip: !kIsWeb); + + test('throws error when aborted with Abort signal', () async { + final instance = FirebaseFunctions.instance; + instance.useFunctionsEmulator('localhost', 5001); - final completer = Completer(); + final completer = Completer(); - final timeoutCallable = FirebaseFunctions.instance.httpsCallable( - kTestFunctionTimeout, - options: HttpsCallableOptions( - webAbortSignal: Abort('aborted'), - ), - ); + final timeoutCallable = FirebaseFunctions.instance.httpsCallable( + kTestFunctionTimeout, + options: HttpsCallableOptions(webAbortSignal: Abort('aborted')), + ); - timeoutCallable.stream({ - 'testTimeout': - const Duration(seconds: 6).inMilliseconds.toString(), - }).listen( - (data) { - completer.completeError('Should have thrown'); - }, - onError: (error) { - if (error is FirebaseFunctionsException) { - expect(error.code, equals('internal')); - completer.complete(); - } else { - completer.completeError('Unexpected error type: $error'); - } - }, - ); - await completer.future; - }, - skip: !kIsWeb, - ); + timeoutCallable + .stream({ + 'testTimeout': const Duration( + seconds: 6, + ).inMilliseconds.toString(), + }) + .listen( + (data) { + completer.completeError('Should have thrown'); + }, + onError: (error) { + if (error is FirebaseFunctionsException) { + expect(error.code, equals('internal')); + completer.complete(); + } else { + completer.completeError('Unexpected error type: $error'); + } + }, + ); + await completer.future; + }, skip: !kIsWeb); }, // Desktop C++ SDK skip: streaming callables are not supported by the // firebase-cpp-sdk.