From a8c6764bada5ede028561cbec5f49db58d7e9590 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 17 Oct 2026 17:23:01 -0700 Subject: [PATCH] fix(mobile): bound queued media authentication work Signed-off-by: Tom Brow --- mobile/lib/shared/relay/media_auth.dart | 12 +++++++++- .../test/shared/relay/media_image_test.dart | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index 26d167547e..b3f2359a6b 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -37,6 +37,9 @@ class MediaGetAuthService { Map? _cachedHeaders; DateTime? _refreshAt; + // Includes the running job. Bound retained work during rapid history scrolls. + static const _maxPendingSignatures = 64; + int _pendingSignatures = 0; Future _signingTail = Future.value(); MediaGetAuthService({ @@ -58,12 +61,18 @@ class MediaGetAuthService { /// /// Image loading starts during layout. Serialize signing per auth scope so a /// viewport full of uncached images does not spawn an isolate per row at once. - /// Timestamp each proof when its queued job starts, not when it was enqueued. + /// At most 64 requests may be outstanding. Excess requests fail explicitly + /// without spawning or retaining work; image failures are evicted so a later + /// resolution can retry. Timestamp proofs when their queued jobs start. Future> headersForAsync(String url) { final nsec = _nsec; if (nsec == null || nsec.isEmpty || !isRelayMediaUrl(url)) { return Future.value(const {}); } + if (_pendingSignatures >= _maxPendingSignatures) { + return Future.error(StateError('Media authentication queue is full')); + } + _pendingSignatures++; final released = Completer(); final result = _signingTail.then((_) async { try { @@ -76,6 +85,7 @@ class MediaGetAuthService { } finally { // A failed worker must propagate to its fetch without blocking later // requests in the queue. + _pendingSignatures--; released.complete(); } }); diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index b3d8b7a5bf..e79276cf72 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -102,6 +102,30 @@ void main() { }, ); + test('bounds queued signing and admits requests after it drains', () async { + final auth = _auth(nsec: nostr.Keys.generate().nsec); + // Enqueue in one turn, before a worker can finish. Record every failure + // immediately so overflow futures cannot become unhandled errors. + final requests = List.generate( + 80, + (_) => auth + .headersForAsync(_mediaUrl) + .then( + (headers) => headers, + onError: (Object error) => error, + ), + ); + final results = await Future.wait(requests); + expect(results.whereType>(), hasLength(64)); + final rejected = results.whereType().toList(); + expect(rejected, hasLength(16)); + expect( + rejected.map((error) => error.message), + everyElement('Media authentication queue is full'), + ); + expect(await auth.headersForAsync(_mediaUrl), isNotEmpty); + }); + test('keeps the signing identity separate across auth scopes', () async { final first = nostr.Keys.generate(); final second = nostr.Keys.generate();