fix(player): scope event-channel cancel to the owning instance

Player backends share static per-backend EventChannel names, so two
overlapping instances (episode handoff, quick exit/reopen) collide:
the newer instance's listen displaces the older sink, the older
instance's late cancel then tears down the newer stream — silently
freezing its events — and the final cancel earns the engine's
"No active stream to cancel" reply, which the framework can only
report as an uncatchable fatal FlutterError (363 events).

Track the owning instance per channel: only the current owner sends
the native cancel; a displaced instance drops its dead subscription.
Residual benign teardown races are dropped in beforeSend.
This commit is contained in:
edde746
2026-07-05 14:16:04 +02:00
parent 61f7d33fdf
commit be072365fc
2 changed files with 29 additions and 6 deletions
+4
View File
@@ -313,6 +313,10 @@ FutureOr<SentryEvent?> _beforeSend(SentryEvent event, Hint _) {
}
// Native HTTP errors from CFNetwork (server errors, not actionable)
if (e.type == 'HTTPClientError') return true;
// Benign EventChannel teardown race: the engine replies this when a
// 'cancel' lands after the stream is already gone, and the framework
// reports it via FlutterError — nothing was ever wrong user-side.
if (e.type == 'PlatformException' && v != null && v.contains('No active stream to cancel')) return true;
// Discord RPC errors when Discord is not running
if (e.type == 'DiscordStateException') return true;
return false;
+25 -6
View File
@@ -95,7 +95,17 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
}
}
/// Backends expose static per-backend [EventChannel]s, so two overlapping
/// instances (episode handoff, quick exit/reopen) share one channel name.
/// The engine allows a single active stream per channel: a newer instance's
/// listen displaces the older sink, and the older instance's late cancel
/// would then tear down the *newer* stream — leaving it eventless — while
/// the final cancel gets an engine "No active stream to cancel" error.
/// Only the instance recorded here may send the native cancel.
static final Map<String, PlayerBase> _eventChannelOwners = {};
void _setupEventListener() {
_eventChannelOwners[eventChannel.name] = this;
_eventSubscription = eventChannel.receiveBroadcastStream().listen(
_handleEvent,
onError: (error) {
@@ -779,13 +789,22 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
if (_disposed) return;
_disposed = true;
try {
await _eventSubscription?.cancel();
} on PlatformException catch (e, st) {
appLogger.d('Player event stream already detached during dispose', error: e, stackTrace: st);
} on MissingPluginException catch (e, st) {
appLogger.d('Player event stream plugin missing during dispose', error: e, stackTrace: st);
if (identical(_eventChannelOwners[eventChannel.name], this)) {
_eventChannelOwners.remove(eventChannel.name);
try {
await _eventSubscription?.cancel();
} on PlatformException catch (e, st) {
appLogger.d('Player event stream already detached during dispose', error: e, stackTrace: st);
} on MissingPluginException catch (e, st) {
appLogger.d('Player event stream plugin missing during dispose', error: e, stackTrace: st);
}
} else {
// A newer instance owns the channel; cancelling would send a stray
// native 'cancel' that kills *its* stream. Drop ours without cancelling
// — the newer listen already replaced this subscription's routing.
appLogger.d('Player event stream handed off to a newer instance, skipping cancel');
}
_eventSubscription = null;
await _logSubscription?.cancel();
try {
await methodChannel.invokeMethod('dispose', {