fix(player): recover playback after a lost native-channel handoff
A player whose predecessor had not released the shared native channel within three seconds skipped its own native dispose and chained its release onto that predecessor. If the predecessor's teardown never completed - one hung 4K session was enough - every later player waited on the chain, failed to initialize, and playback stayed broken with "Playback could not be started" until the app was killed. Initialize and dispose now carry the creating instance's token. The Android plugins remember which token created the current core and acknowledge a dispose from any other token without touching the core, so a dispose that lost the ownership race is provably stale and safe to send. With that guard, a timed-out ownership wait force-disposes instead of skipping, settles its release unchained, and frees the channel for the next session; a dispose watchdog answers Dart even if a native teardown hangs, leaking that one core instead of wedging all future playback. Commands wait eight seconds (was three) so a slow but healthy teardown delays the next session instead of failing it. Verified on a Shield Pro: 38 back-to-back session races at 0.8-2.2s gaps with zero failures and balanced teardowns, and a deep-link-over- playback collision whose stale dispose is ignored, after which Back returns to the still-playing session and Retry starts the new one. Apple and desktop handlers ignore the token and keep the historical skip semantics until they gain the guard.
This commit is contained in:
@@ -41,6 +41,18 @@ final class _InvokingPlayerNative extends PlayerNative {
|
||||
Future<T?> debugInvoke<T>(String method) => invoke<T>(method);
|
||||
}
|
||||
|
||||
final class _GuardedPlayerNative extends PlayerNative {
|
||||
@override
|
||||
bool get nativeDisposeIsStaleGuarded => true;
|
||||
}
|
||||
|
||||
final class _GuardedInvokingPlayerNative extends PlayerNative {
|
||||
@override
|
||||
bool get nativeDisposeIsStaleGuarded => true;
|
||||
|
||||
Future<T?> debugInvoke<T>(String method) => invoke<T>(method);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -85,7 +97,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('video initialize carries the decode intent for the Android vo choice, audio stays bare (#2010)', () async {
|
||||
test('initialize carries the decode intent and the instance token (#2010)', () async {
|
||||
for (final hardwareDecoding in [true, false]) {
|
||||
final calls = <MethodCall>[];
|
||||
await withMockPlayerChannels(
|
||||
@@ -101,7 +113,7 @@ void main() {
|
||||
try {
|
||||
await player.setLogLevel('warn');
|
||||
final init = calls.singleWhere((call) => call.method == 'initialize');
|
||||
expect(init.arguments, {'hardwareDecoding': hardwareDecoding});
|
||||
expect(init.arguments, {'hardwareDecoding': hardwareDecoding, 'instanceId': player.nativeInstanceId});
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
@@ -123,7 +135,7 @@ void main() {
|
||||
try {
|
||||
await player.setLogLevel('warn');
|
||||
final init = audioCalls.singleWhere((call) => call.method == 'initialize');
|
||||
expect(init.arguments, isNull);
|
||||
expect(init.arguments, {'instanceId': player.nativeInstanceId});
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
@@ -258,7 +270,11 @@ void main() {
|
||||
|
||||
test('invoke returns null when a predecessor release remains stalled', () async {
|
||||
PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(milliseconds: 5);
|
||||
addTearDown(() => PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3));
|
||||
PlayerBase.debugNativeOwnershipInvokeTimeout = const Duration(milliseconds: 5);
|
||||
addTearDown(() {
|
||||
PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3);
|
||||
PlayerBase.debugNativeOwnershipInvokeTimeout = const Duration(seconds: 8);
|
||||
});
|
||||
final stalledNativeDispose = Completer<void>();
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
@@ -299,6 +315,64 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('a stale-guarded backend force-disposes past a hung predecessor and frees the channel', () async {
|
||||
PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(milliseconds: 5);
|
||||
PlayerBase.debugNativeOwnershipInvokeTimeout = const Duration(milliseconds: 50);
|
||||
addTearDown(() {
|
||||
PlayerBase.debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3);
|
||||
PlayerBase.debugNativeOwnershipInvokeTimeout = const Duration(seconds: 8);
|
||||
});
|
||||
// The first player's native dispose never completes — the hung-teardown
|
||||
// scenario that used to wedge every later session behind a release chain.
|
||||
// Later disposes answer normally, standing in for the plugin's dispose
|
||||
// watchdog and stale-token acknowledgement.
|
||||
final hungNativeDispose = Completer<void>();
|
||||
var sawFirstDispose = false;
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: (call) {
|
||||
calls.add(call);
|
||||
if (call.method == 'initialize') return Future.value(true);
|
||||
if (call.method == 'dispose' && !sawFirstDispose) {
|
||||
sawFirstDispose = true;
|
||||
return hungNativeDispose.future;
|
||||
}
|
||||
return Future.value(null);
|
||||
},
|
||||
testBody: () async {
|
||||
final first = _GuardedPlayerNative();
|
||||
final second = _GuardedPlayerNative();
|
||||
final third = _GuardedInvokingPlayerNative();
|
||||
Future<void>? firstDisposal;
|
||||
try {
|
||||
await first.setLogLevel('warn');
|
||||
firstDisposal = first.dispose();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
// The successor's dispose times out waiting, then force-sends its
|
||||
// own token instead of skipping, and settles its release at once.
|
||||
await second.dispose().timeout(const Duration(seconds: 1));
|
||||
final disposes = calls.where((call) => call.method == 'dispose').toList();
|
||||
expect(disposes, hasLength(2));
|
||||
expect((disposes.last.arguments as Map)['instanceId'], second.nativeInstanceId);
|
||||
|
||||
// The channel is usable again while the hung teardown is still
|
||||
// pending: the third player's commands are not wedged behind it.
|
||||
await third.setLogLevel('warn');
|
||||
expect(calls.where((call) => call.method == 'initialize'), hasLength(2));
|
||||
} finally {
|
||||
if (!hungNativeDispose.isCompleted) hungNativeDispose.complete();
|
||||
await firstDisposal;
|
||||
await second.dispose();
|
||||
await third.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('initialization cannot publish readiness after disposal starts', () async {
|
||||
final initialize = Completer<bool>();
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
Reference in New Issue
Block a user