diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index 534fe1005d..85f740138e 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -50,6 +50,8 @@ class MediaGetAuthService { // Includes the running job. Bound retained work during rapid history scrolls. static const _maxPendingSignatures = 64; int _pendingSignatures = 0; + static const _maxPendingRetries = 64; + int _pendingRetries = 0; Future _signingTail = Future.value(); MediaGetAuthService({ @@ -107,13 +109,20 @@ class MediaGetAuthService { /// Keeps an in-flight consumer pending through one queue-saturation retry. /// /// Waits for the existing queue snapshot, then competes for normal admission. - /// A second rejection propagates; no retry loop or extra signing queue exists. + /// At most 64 consumers may wait or retry in addition to the 64 signing jobs. + /// Excess waiters and second rejections fail without a retry loop. Future> headersForAsyncWithRetry(String url) async { try { return await headersForAsync(url); } on MediaAuthQueueFull catch (error) { - await error.drained; - return headersForAsync(url); + if (_pendingRetries >= _maxPendingRetries) rethrow; + _pendingRetries++; + try { + await error.drained; + return await headersForAsync(url); + } finally { + _pendingRetries--; + } } } diff --git a/mobile/test/features/channels/media_queue_recovery_test.dart b/mobile/test/features/channels/media_queue_recovery_test.dart index d4232da06a..04fea0513b 100644 --- a/mobile/test/features/channels/media_queue_recovery_test.dart +++ b/mobile/test/features/channels/media_queue_recovery_test.dart @@ -61,6 +61,36 @@ class _VideoPlatform extends VideoPlayerPlatform { } void main() { + test('bounds retry waiters before signing capacity drains', () async { + final auth = _SaturatedAuth(alwaysReject: true); + var rejected = 0; + final requests = List.generate( + 80, + (_) => auth + .headersForAsyncWithRetry('https://example.com/media/a') + .then( + (headers) => headers, + onError: (Object error) { + rejected++; + return error; + }, + ), + ); + await Future.delayed(Duration.zero); + expect(auth.calls, 80); + expect(rejected, 16); + auth.drained.complete(); + await Future.wait(requests); + expect(auth.calls, 144); + expect(rejected, 80); + // Retry permits are released even when the second admission fails. + await expectLater( + auth.headersForAsyncWithRetry('https://example.com/media/a'), + throwsA(isA()), + ); + expect(auth.calls, 146); + }); + test('retries saturation only once', () async { final auth = _SaturatedAuth(alwaysReject: true); final result = auth.headersForAsyncWithRetry('https://example.com/media/a');