From 08e661c5e1e9f649e798b576999263446886ed82 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 11:11:12 +0000 Subject: [PATCH 1/3] fix(sse): scope dispose to per-screen provider instance SSE subscriptions and HTTP clients were stored in static maps but Screen.dispose() calls dispose on cloned API providers. Disposing any screen cancelled every SSE connection app-wide, breaking live streams on still-mounted screens after navigation, modals, or tab switches. Make subscription state instance-scoped (matching FirestoreAPIProvider) and resolve getProvider('sse') from the screen provider map instead of allocating a throwaway instance on each lookup. Co-authored-by: Sharjeel Yunus --- .../framework/apiproviders/api_provider.dart | 2 +- .../apiproviders/sse_api_provider.dart | 24 ++++++--- .../test/sse_provider_dispose_test.dart | 53 +++++++++++++++++++ 3 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 modules/ensemble/test/sse_provider_dispose_test.dart diff --git a/modules/ensemble/lib/framework/apiproviders/api_provider.dart b/modules/ensemble/lib/framework/apiproviders/api_provider.dart index f17867d9e..138230427 100644 --- a/modules/ensemble/lib/framework/apiproviders/api_provider.dart +++ b/modules/ensemble/lib/framework/apiproviders/api_provider.dart @@ -31,7 +31,7 @@ class APIProviders extends InheritedWidget { } else if (provider == 'firebaseFunction') { return FirebaseFunctionsAPIProvider(); } else if (provider == 'sse') { - return SSEAPIProvider(); + return providers['sse'] ?? SSEAPIProvider(); } else { return providers[provider] ?? httpProvider; } diff --git a/modules/ensemble/lib/framework/apiproviders/sse_api_provider.dart b/modules/ensemble/lib/framework/apiproviders/sse_api_provider.dart index e06fe4c22..33df0d418 100644 --- a/modules/ensemble/lib/framework/apiproviders/sse_api_provider.dart +++ b/modules/ensemble/lib/framework/apiproviders/sse_api_provider.dart @@ -24,9 +24,9 @@ import 'package:crypto/crypto.dart'; /// Server-Sent Events (SSE) API Provider /// Handles streaming HTTP connections class SSEAPIProvider extends APIProvider with LiveAPIProvider { - static final Map _subscriptions = {}; - static final Map _activeClients = {}; - static final Set _manuallyDisconnected = {}; + final Map _subscriptions = {}; + final Map _activeClients = {}; + final Set _manuallyDisconnected = {}; @override Future init(String appId, Map config) async { @@ -530,22 +530,30 @@ class SSEAPIProvider extends APIProvider with LiveAPIProvider { @override dispose() { - _manuallyDisconnected.addAll(_subscriptions.keys); + for (final apiName in _subscriptions.keys.toList()) { + _manuallyDisconnected.add(apiName); + } - // Cancel all subscriptions - for (var subscription in _subscriptions.values) { + for (final subscription in _subscriptions.values) { subscription.cancel(); } _subscriptions.clear(); - // Close all active clients - for (var client in _activeClients.values) { + for (final client in _activeClients.values) { client.close(); } _activeClients.clear(); _manuallyDisconnected.clear(); } + + @visibleForTesting + int get subscriptionCountForTesting => _subscriptions.length; + + @visibleForTesting + void trackSubscriptionForTesting(String apiName, StreamSubscription sub) { + _subscriptions[apiName] = sub; + } } /// Configuration options for SSE connections diff --git a/modules/ensemble/test/sse_provider_dispose_test.dart b/modules/ensemble/test/sse_provider_dispose_test.dart new file mode 100644 index 000000000..c4a9c8ab9 --- /dev/null +++ b/modules/ensemble/test/sse_provider_dispose_test.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:ensemble/framework/apiproviders/api_provider.dart'; +import 'package:ensemble/framework/apiproviders/http_api_provider.dart'; +import 'package:ensemble/framework/apiproviders/sse_api_provider.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SSEAPIProvider.dispose', () { + test('cancels only this instance subscriptions', () async { + final providerA = SSEAPIProvider(); + final providerB = SSEAPIProvider(); + + final controllerA = StreamController(); + final controllerB = StreamController(); + var aCanceled = false; + var bCanceled = false; + + providerA.trackSubscriptionForTesting( + 'liveFeed', + controllerA.stream.listen((_) {}, onDone: () => aCanceled = true), + ); + providerB.trackSubscriptionForTesting( + 'metrics', + controllerB.stream.listen((_) {}, onDone: () => bCanceled = true), + ); + + providerA.dispose(); + + expect(providerA.subscriptionCountForTesting, 0); + expect(providerB.subscriptionCountForTesting, 1); + + await controllerA.close(); + await controllerB.close(); + + expect(aCanceled, isTrue); + expect(bCanceled, isFalse); + }); + }); + + group('APIProviders.getProvider', () { + test('returns cloned sse provider from screen map', () { + final sse = SSEAPIProvider(); + final providers = APIProviders( + providers: {'sse': sse, 'http': HTTPAPIProvider()}, + child: const SizedBox.shrink(), + ); + + expect(identical(providers.getProvider('sse'), sse), isTrue); + }); + }); +} From 288c4a8ce5a7b659532e0bee72ac720669096d18 Mon Sep 17 00:00:00 2001 From: Sharjeel Yunus <61178058+sharjeelyunus@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:27:19 +0500 Subject: [PATCH 2/3] Update modules/ensemble/lib/framework/apiproviders/api_provider.dart --- modules/ensemble/lib/framework/apiproviders/api_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ensemble/lib/framework/apiproviders/api_provider.dart b/modules/ensemble/lib/framework/apiproviders/api_provider.dart index 138230427..8b66e8346 100644 --- a/modules/ensemble/lib/framework/apiproviders/api_provider.dart +++ b/modules/ensemble/lib/framework/apiproviders/api_provider.dart @@ -31,7 +31,7 @@ class APIProviders extends InheritedWidget { } else if (provider == 'firebaseFunction') { return FirebaseFunctionsAPIProvider(); } else if (provider == 'sse') { - return providers['sse'] ?? SSEAPIProvider(); +return SSEAPIProvider(); } else { return providers[provider] ?? httpProvider; } From 490301762c4f94e8c0c161f4e122624fb33ab313 Mon Sep 17 00:00:00 2001 From: Sharjeel Yunus Date: Thu, 11 Jun 2026 02:02:28 +0500 Subject: [PATCH 3/3] refactor(api): enhance provider resolution and improve SSE cancellation handling Updated the APIProviders class to utilize a map for provider resolution, ensuring a fallback to SSEAPIProvider when necessary. Additionally, modified the SSE provider tests to verify cancellation behavior of StreamControllers, ensuring proper resource management during disposal. --- .../lib/framework/apiproviders/api_provider.dart | 2 +- .../ensemble/test/sse_provider_dispose_test.dart | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/modules/ensemble/lib/framework/apiproviders/api_provider.dart b/modules/ensemble/lib/framework/apiproviders/api_provider.dart index 8b66e8346..2dd658927 100644 --- a/modules/ensemble/lib/framework/apiproviders/api_provider.dart +++ b/modules/ensemble/lib/framework/apiproviders/api_provider.dart @@ -31,7 +31,7 @@ class APIProviders extends InheritedWidget { } else if (provider == 'firebaseFunction') { return FirebaseFunctionsAPIProvider(); } else if (provider == 'sse') { -return SSEAPIProvider(); + return providers[provider] ?? SSEAPIProvider(); } else { return providers[provider] ?? httpProvider; } diff --git a/modules/ensemble/test/sse_provider_dispose_test.dart b/modules/ensemble/test/sse_provider_dispose_test.dart index c4a9c8ab9..e1201f786 100644 --- a/modules/ensemble/test/sse_provider_dispose_test.dart +++ b/modules/ensemble/test/sse_provider_dispose_test.dart @@ -12,30 +12,29 @@ void main() { final providerA = SSEAPIProvider(); final providerB = SSEAPIProvider(); - final controllerA = StreamController(); - final controllerB = StreamController(); var aCanceled = false; var bCanceled = false; + final controllerA = StreamController(onCancel: () => aCanceled = true); + final controllerB = StreamController(onCancel: () => bCanceled = true); providerA.trackSubscriptionForTesting( 'liveFeed', - controllerA.stream.listen((_) {}, onDone: () => aCanceled = true), + controllerA.stream.listen((_) {}), ); providerB.trackSubscriptionForTesting( 'metrics', - controllerB.stream.listen((_) {}, onDone: () => bCanceled = true), + controllerB.stream.listen((_) {}), ); providerA.dispose(); expect(providerA.subscriptionCountForTesting, 0); expect(providerB.subscriptionCountForTesting, 1); + expect(aCanceled, isTrue); + expect(bCanceled, isFalse); await controllerA.close(); await controllerB.close(); - - expect(aCanceled, isTrue); - expect(bCanceled, isFalse); }); });