fix(mobile): bound queued media authentication work

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-17 17:23:01 -07:00
parent 06123e3866
commit a8c6764bad
2 changed files with 35 additions and 1 deletions

View File

@@ -37,6 +37,9 @@ class MediaGetAuthService {
Map<String, String>? _cachedHeaders;
DateTime? _refreshAt;
// Includes the running job. Bound retained work during rapid history scrolls.
static const _maxPendingSignatures = 64;
int _pendingSignatures = 0;
Future<void> _signingTail = Future<void>.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<Map<String, String>> 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<void>();
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();
}
});

View File

@@ -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<Object>(
(headers) => headers,
onError: (Object error) => error,
),
);
final results = await Future.wait(requests);
expect(results.whereType<Map<String, String>>(), hasLength(64));
final rejected = results.whereType<StateError>().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();