mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
When push setup fails, the app retried every 5 seconds forever. On an iPhone 12 mini whose push gateway enrollment is refused (a CDN 403 on the installation challenge), each retry refetched the relay's NIP-11 document over a new TLS connection and logged the whole HTML refusal. That is about 720 attempts and roughly 14 MB of traffic an hour for as long as the app stays open, with no way to succeed. Push registration, lease publication and tombstone retries now back off: each consecutive failure of the same attempt doubles the delay from 5 seconds up to 15 minutes, with jitter between half and all of it so devices that failed together don't retry together. A success (including a renewal), a different attempt, or a channel-grammar retry (which keeps its 5-minute delay) ends the streak. The first retry still comes within 5 seconds, so a brief blip recovers as quickly as before. ### Validation - Added tests: `repeated failures back off exponentially up to a cap` checks every delay's bounds at both jitter extremes over 12 failures, `a success ends the failure streak`, and `a different attempt starts its own backoff`. The first fails when the gate goes back to a fixed delay. - Existing push suite passes. - On the 12 mini before this change: `Push lease bootstrap failed` with the 403 page and a fresh NIP-11 fetch every 5 seconds for the whole session. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
597 lines
19 KiB
Dart
597 lines
19 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../community/community.dart';
|
|
import '../community/community_provider.dart';
|
|
import '../relay/relay_provider.dart';
|
|
import '../relay/relay_session.dart';
|
|
import '../relay/signed_event_relay.dart';
|
|
import 'dev_push_lease.dart';
|
|
import 'push_bridge.dart';
|
|
import 'push_lease_revocation_outbox.dart';
|
|
import 'push_relay_capability_provider.dart';
|
|
import 'push_subscription.dart';
|
|
import 'push_subscription_provider.dart';
|
|
import 'push_setup_recovery.dart';
|
|
|
|
const _pushBootstrapRetryDelay = Duration(seconds: 5);
|
|
const _pushBootstrapMaxRetryDelay = Duration(minutes: 15);
|
|
|
|
@visibleForTesting
|
|
class BuzzPushAttemptGate {
|
|
BuzzPushAttemptGate({
|
|
this.retryDelay = _pushBootstrapRetryDelay,
|
|
this.maxRetryDelay = _pushBootstrapMaxRetryDelay,
|
|
double Function()? jitter,
|
|
}) : _jitter = jitter ?? Random().nextDouble;
|
|
|
|
/// Delay before the first retry of a failing attempt. Each further
|
|
/// consecutive failure of the same attempt doubles it, up to
|
|
/// [maxRetryDelay], so a persistent refusal (for example a gateway that
|
|
/// blocks this device) is not retried every few seconds forever.
|
|
final Duration retryDelay;
|
|
final Duration maxRetryDelay;
|
|
final double Function() _jitter;
|
|
String? _attempt;
|
|
Timer? _retryTimer;
|
|
String? _failingAttempt;
|
|
int _consecutiveFailures = 0;
|
|
|
|
bool tryBegin(String attempt) {
|
|
if (_attempt == attempt) return false;
|
|
_retryTimer?.cancel();
|
|
_retryTimer = null;
|
|
_attempt = attempt;
|
|
return true;
|
|
}
|
|
|
|
void failed(String attempt, {required VoidCallback retry}) {
|
|
if (_attempt != attempt) return;
|
|
if (_failingAttempt != attempt) {
|
|
_failingAttempt = attempt;
|
|
_consecutiveFailures = 0;
|
|
}
|
|
_schedule(attempt, _backoff(_consecutiveFailures++), retry);
|
|
}
|
|
|
|
/// Between half and all of the capped exponential delay, so devices that
|
|
/// failed together do not retry together.
|
|
Duration _backoff(int priorFailures) {
|
|
final ceiling = min(
|
|
maxRetryDelay.inMilliseconds,
|
|
retryDelay.inMilliseconds * (1 << min(priorFailures, 20)),
|
|
);
|
|
final half = ceiling ~/ 2;
|
|
return Duration(
|
|
milliseconds: half + (_jitter() * (ceiling - half)).floor(),
|
|
);
|
|
}
|
|
|
|
void _schedule(String attempt, Duration delay, VoidCallback retry) {
|
|
_retryTimer?.cancel();
|
|
_retryTimer = Timer(delay, () {
|
|
_retryTimer = null;
|
|
if (_attempt != attempt) return;
|
|
_attempt = null;
|
|
retry();
|
|
});
|
|
}
|
|
|
|
/// Schedules a retry after a success (for example a renewal) or after a
|
|
/// failure that picks its own delay. Ends any failure streak.
|
|
void retryAfter(
|
|
String attempt, {
|
|
required Duration delay,
|
|
required VoidCallback retry,
|
|
}) {
|
|
if (_attempt != attempt) return;
|
|
_endFailureStreak();
|
|
_schedule(attempt, delay, retry);
|
|
}
|
|
|
|
void complete(String attempt) {
|
|
if (_attempt != attempt) return;
|
|
_retryTimer?.cancel();
|
|
_retryTimer = null;
|
|
_attempt = null;
|
|
_endFailureStreak();
|
|
}
|
|
|
|
/// Records that [attempt] succeeded without making it runnable again, so a
|
|
/// later failure starts from the first retry delay.
|
|
void succeeded(String attempt) {
|
|
if (_attempt == attempt) _endFailureStreak();
|
|
}
|
|
|
|
void _endFailureStreak() {
|
|
_failingAttempt = null;
|
|
_consecutiveFailures = 0;
|
|
}
|
|
|
|
void dispose() => _retryTimer?.cancel();
|
|
}
|
|
|
|
@visibleForTesting
|
|
String buzzPushPublicationAttemptKey({
|
|
required String communityId,
|
|
required String relayBaseUrl,
|
|
required String token,
|
|
required BuzzPushLeaseDescriptor descriptor,
|
|
required List<BuzzPushSubscription> subscriptions,
|
|
}) => [
|
|
communityId,
|
|
relayBaseUrl,
|
|
token,
|
|
descriptor.executorKeyId,
|
|
descriptor.executorPubkey,
|
|
descriptor.hGrammar,
|
|
buzzPushSubscriptionsFingerprint(subscriptions),
|
|
].join('|');
|
|
|
|
/// Identifies a pass over pending opt-out tombstones by the communities it
|
|
/// covers, so a community whose tombstone appears while another is backing
|
|
/// off runs now instead of waiting out that delay. Not by generation: each
|
|
/// pass advances it, and that must not restart the backoff.
|
|
@visibleForTesting
|
|
String buzzPushTombstoneAttemptKey(Iterable<Community> pending) =>
|
|
[for (final community in pending) community.id].join('|');
|
|
|
|
@visibleForTesting
|
|
bool buzzPushLifecycleEnabled({
|
|
required Community? community,
|
|
required BuzzPushLeaseDescriptor? descriptor,
|
|
}) =>
|
|
Env.pushGatewayConfigured &&
|
|
community?.pushNotificationsEnabled == true &&
|
|
descriptor != null;
|
|
|
|
/// Starts APNs registration when the active community opts in.
|
|
@visibleForTesting
|
|
class BuzzPushRegistrationBootstrap extends HookWidget {
|
|
const BuzzPushRegistrationBootstrap({
|
|
required this.shouldRegister,
|
|
required this.attemptKey,
|
|
required this.child,
|
|
this.startRegistration = startBuzzPushRegistration,
|
|
super.key,
|
|
});
|
|
|
|
final bool shouldRegister;
|
|
final String attemptKey;
|
|
final Widget child;
|
|
final Future<void> Function() startRegistration;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final attemptGate = useMemoized(BuzzPushAttemptGate.new);
|
|
final retry = useState(0);
|
|
useEffect(() => attemptGate.dispose, const []);
|
|
useEffect(() {
|
|
if (!shouldRegister || !attemptGate.tryBegin(attemptKey)) return null;
|
|
unawaited(() async {
|
|
try {
|
|
await startRegistration();
|
|
attemptGate.succeeded(attemptKey);
|
|
} catch (error, stack) {
|
|
attemptGate.failed(
|
|
attemptKey,
|
|
retry: () {
|
|
if (context.mounted) retry.value += 1;
|
|
},
|
|
);
|
|
debugPrint('Push registration bootstrap failed: $error');
|
|
debugPrintStack(stackTrace: stack);
|
|
}
|
|
}());
|
|
return null;
|
|
}, [shouldRegister, attemptKey, retry.value]);
|
|
return child;
|
|
}
|
|
}
|
|
|
|
@visibleForTesting
|
|
Future<int> publishBuzzPushLeaseRecoverably({
|
|
required Future<int> Function() reserveGeneration,
|
|
required Future<void> Function(int generation) publish,
|
|
required Future<bool> Function(int generation) markAccepted,
|
|
}) async {
|
|
final generation = await reserveGeneration();
|
|
await publish(generation);
|
|
if (!await markAccepted(generation)) {
|
|
throw StateError('A newer push lease superseded the published generation.');
|
|
}
|
|
return generation;
|
|
}
|
|
|
|
typedef _PushInputs = ({
|
|
BuzzPushLeaseRevocationOutbox revocationOutbox,
|
|
SessionState session,
|
|
List<Community> communities,
|
|
RelayConfig config,
|
|
Community? community,
|
|
String? memberPubkey,
|
|
BuzzPushLeaseDescriptor? descriptor,
|
|
});
|
|
|
|
// Convert synchronous provider failures into data before they reach a widget
|
|
// build. Dependencies remain watched so corrected inputs recover immediately.
|
|
final _pushInputsProvider = Provider.autoDispose<AsyncValue<_PushInputs>>((
|
|
ref,
|
|
) {
|
|
try {
|
|
return AsyncData((
|
|
revocationOutbox: ref.watch(buzzPushLeaseRevocationOutboxProvider),
|
|
session: ref.watch(relaySessionProvider),
|
|
communities: ref.watch(communityListProvider).value ?? const [],
|
|
config: ref.watch(relayConfigProvider),
|
|
community: ref.watch(activeCommunityProvider).value,
|
|
memberPubkey: ref.watch(myPubkeyProvider),
|
|
descriptor: ref.watch(currentRelayPushDescriptorProvider).value,
|
|
));
|
|
} catch (error, stack) {
|
|
reportPushSetupError('initialization', error, stack);
|
|
return AsyncError(error, stack);
|
|
}
|
|
});
|
|
|
|
// Pull cold-start notification routing after runApp. A failed platform call
|
|
// must not prevent the first frame or disappear as an unobserved future.
|
|
final _pendingNotificationResponseProvider = FutureProvider.autoDispose<void>((
|
|
ref,
|
|
) async {
|
|
try {
|
|
await syncPendingBuzzPushNotificationResponse();
|
|
} catch (error, stack) {
|
|
reportPushSetupError('pending notification response', error, stack);
|
|
rethrow;
|
|
}
|
|
}, retry: pushSetupRetry);
|
|
|
|
/// Observable startup/synchronization status, independent of core app rendering.
|
|
final buzzPushSetupStatusProvider = Provider.autoDispose<AsyncValue<void>>((
|
|
ref,
|
|
) {
|
|
final inputs = ref.watch(_pushInputsProvider);
|
|
final response = ref.watch(_pendingNotificationResponseProvider);
|
|
final subscriptions = ref.watch(pushSubscriptionSyncProvider);
|
|
if (response.hasError) return response;
|
|
if (inputs.hasError) return AsyncError(inputs.error!, inputs.stackTrace!);
|
|
return subscriptions;
|
|
});
|
|
|
|
/// Retries push-owned setup dependencies without resetting the app or session.
|
|
void retryBuzzPushSetup(WidgetRef ref) {
|
|
// Unrelated setup failures must not dispose a healthy cleanup worker.
|
|
// Reconstruct only a worker whose provider actually failed to initialize.
|
|
try {
|
|
ref.read(buzzPushLeaseRevocationOutboxProvider);
|
|
} catch (error, stack) {
|
|
reportPushSetupError('revocation worker retry', error, stack);
|
|
ref.invalidate(buzzPushLeaseRevocationOutboxProvider);
|
|
}
|
|
ref.invalidate(currentRelayPushDescriptorProvider);
|
|
ref.invalidate(pushSubscriptionSyncProvider);
|
|
ref.invalidate(_pendingNotificationResponseProvider);
|
|
ref.invalidate(_pushInputsProvider);
|
|
}
|
|
|
|
/// Starts push independently of [child], which stays mounted during failures.
|
|
class BuzzPushBootstrap extends HookConsumerWidget {
|
|
const BuzzPushBootstrap({required this.child, super.key});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final inputs = ref.watch(_pushInputsProvider);
|
|
ref.watch(buzzPushSetupStatusProvider);
|
|
final failures = useRef(0);
|
|
useEffect(() {
|
|
if (!inputs.hasError) {
|
|
failures.value = 0;
|
|
return null;
|
|
}
|
|
final delay = pushSetupRetry(failures.value++, inputs.error!);
|
|
if (delay == null) return null;
|
|
final timer = Timer(delay, () {
|
|
if (context.mounted) retryBuzzPushSetup(ref);
|
|
});
|
|
return timer.cancel;
|
|
}, [inputs]);
|
|
// A stable sibling slot preserves navigation and widget state when push
|
|
// fails, retries, or becomes available after startup.
|
|
return Stack(
|
|
textDirection: TextDirection.ltr,
|
|
children: [
|
|
child,
|
|
if (inputs.asData case final data?)
|
|
_BuzzPushLifecycle(inputs: data.value),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BuzzPushLifecycle extends HookConsumerWidget {
|
|
const _BuzzPushLifecycle({required this.inputs});
|
|
|
|
final _PushInputs inputs;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
useListenable(apnsDeviceToken);
|
|
final publicationAttempt = useMemoized(BuzzPushAttemptGate.new);
|
|
final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new);
|
|
final publicationRetry = useState(0);
|
|
final tombstoneRetry = useState(0);
|
|
final (
|
|
:revocationOutbox,
|
|
:session,
|
|
:communities,
|
|
:config,
|
|
:community,
|
|
:memberPubkey,
|
|
:descriptor,
|
|
) = inputs;
|
|
final enablementEpoch = useRef(0);
|
|
useEffect(() {
|
|
// Each opt-in needs a fresh active lease even when endpoint, executor,
|
|
// and subscriptions match the lease replaced by the opt-out tombstone.
|
|
// A distinct key also fences renewal callbacks from the prior opt-in.
|
|
if (community?.pushNotificationsEnabled == true) {
|
|
enablementEpoch.value += 1;
|
|
}
|
|
return null;
|
|
}, [community?.id, community?.pushNotificationsEnabled]);
|
|
|
|
useEffect(() {
|
|
final listener = AppLifecycleListener(
|
|
onResume: () => _runRevocationOutbox(revocationOutbox.trigger),
|
|
);
|
|
_runRevocationOutbox(revocationOutbox.start);
|
|
return listener.dispose;
|
|
}, [revocationOutbox]);
|
|
|
|
useEffect(() {
|
|
if (session.status == SessionStatus.connected) {
|
|
_runRevocationOutbox(revocationOutbox.trigger);
|
|
}
|
|
return null;
|
|
}, [revocationOutbox, session.status]);
|
|
|
|
useEffect(
|
|
() => () {
|
|
publicationAttempt.dispose();
|
|
tombstoneAttempt.dispose();
|
|
},
|
|
const [],
|
|
);
|
|
|
|
useEffect(
|
|
() {
|
|
final pendingCommunities = communities
|
|
.where(
|
|
(candidate) =>
|
|
!candidate.pushNotificationsEnabled &&
|
|
candidate.pushSubscriptionState.pendingTombstoneGeneration !=
|
|
null,
|
|
)
|
|
.toList();
|
|
if (session.status != SessionStatus.connected ||
|
|
pendingCommunities.isEmpty) {
|
|
return null;
|
|
}
|
|
final attempt = buzzPushTombstoneAttemptKey(pendingCommunities);
|
|
if (!tombstoneAttempt.tryBegin(attempt)) return null;
|
|
unawaited(() async {
|
|
try {
|
|
Object? firstError;
|
|
StackTrace? firstStack;
|
|
for (final pendingCommunity in pendingCommunities) {
|
|
try {
|
|
await ref
|
|
.read(communityListProvider.notifier)
|
|
.retryPendingPushLeaseTombstone(
|
|
pendingCommunity.id,
|
|
advanceGeneration: true,
|
|
);
|
|
} catch (error, stack) {
|
|
firstError ??= error;
|
|
firstStack ??= stack;
|
|
}
|
|
}
|
|
if (firstError != null) {
|
|
Error.throwWithStackTrace(firstError, firstStack!);
|
|
}
|
|
tombstoneAttempt.complete(attempt);
|
|
} catch (error, stack) {
|
|
tombstoneAttempt.failed(
|
|
attempt,
|
|
retry: () {
|
|
if (context.mounted) tombstoneRetry.value += 1;
|
|
},
|
|
);
|
|
debugPrint('Push lease tombstone retry failed: $error');
|
|
debugPrintStack(stackTrace: stack);
|
|
}
|
|
}());
|
|
return null;
|
|
},
|
|
[
|
|
session.status,
|
|
for (final candidate in communities)
|
|
'${candidate.id}|${candidate.pushNotificationsEnabled}|'
|
|
'${candidate.pushSubscriptionState.pendingTombstoneGeneration}',
|
|
tombstoneRetry.value,
|
|
],
|
|
);
|
|
|
|
final token = apnsDeviceToken.value;
|
|
useEffect(
|
|
() {
|
|
if (!_ready(session, config, community, memberPubkey) ||
|
|
!buzzPushLifecycleEnabled(
|
|
community: community,
|
|
descriptor: descriptor,
|
|
) ||
|
|
token == null) {
|
|
return null;
|
|
}
|
|
final activeCommunity = community!;
|
|
final activeDescriptor = descriptor!;
|
|
final state = activeCommunity.pushSubscriptionState;
|
|
if (state.desired.isEmpty) return null;
|
|
final publicationKey = buzzPushPublicationAttemptKey(
|
|
communityId: activeCommunity.id,
|
|
relayBaseUrl: config.baseUrl,
|
|
token: token,
|
|
descriptor: activeDescriptor,
|
|
subscriptions: state.desired,
|
|
);
|
|
final attempt = '${enablementEpoch.value}|$publicationKey';
|
|
if (!publicationAttempt.tryBegin(attempt)) return null;
|
|
final relay = SignedEventRelay(
|
|
session: ref.read(relaySessionProvider.notifier),
|
|
nsec: config.nsec!,
|
|
);
|
|
unawaited(() async {
|
|
try {
|
|
final grant = await _publish(
|
|
ref,
|
|
config,
|
|
activeCommunity,
|
|
memberPubkey!,
|
|
relay,
|
|
);
|
|
final renewInMilliseconds =
|
|
grant.expiresAt * 1000 -
|
|
DateTime.now().millisecondsSinceEpoch -
|
|
const Duration(minutes: 5).inMilliseconds;
|
|
publicationAttempt.retryAfter(
|
|
attempt,
|
|
delay: Duration(
|
|
milliseconds: renewInMilliseconds > 1000
|
|
? renewInMilliseconds
|
|
: 1000,
|
|
),
|
|
retry: () {
|
|
if (context.mounted) publicationRetry.value += 1;
|
|
},
|
|
);
|
|
} catch (error, stack) {
|
|
void retry() {
|
|
if (context.mounted) publicationRetry.value += 1;
|
|
}
|
|
|
|
if (error is BuzzPushChannelGrammarException) {
|
|
// Re-fetch capabilities after a relay upgrade without repeatedly
|
|
// enrolling or publishing a lease the old relay cannot accept.
|
|
publicationAttempt.retryAfter(
|
|
attempt,
|
|
delay: const Duration(minutes: 5),
|
|
retry: retry,
|
|
);
|
|
} else {
|
|
publicationAttempt.failed(attempt, retry: retry);
|
|
}
|
|
debugPrint('Push lease bootstrap failed: $error');
|
|
debugPrintStack(stackTrace: stack);
|
|
}
|
|
}());
|
|
return null;
|
|
},
|
|
[
|
|
session.status,
|
|
config.baseUrl,
|
|
community?.id,
|
|
community?.pushNotificationsEnabled,
|
|
community?.pushSubscriptionState,
|
|
memberPubkey,
|
|
descriptor,
|
|
token,
|
|
publicationRetry.value,
|
|
],
|
|
);
|
|
|
|
return BuzzPushRegistrationBootstrap(
|
|
shouldRegister:
|
|
_ready(session, config, community, memberPubkey) &&
|
|
buzzPushLifecycleEnabled(
|
|
community: community,
|
|
descriptor: descriptor,
|
|
),
|
|
attemptKey: '${community?.id}|${config.baseUrl}',
|
|
child: const SizedBox.shrink(),
|
|
);
|
|
}
|
|
|
|
static bool _ready(
|
|
SessionState session,
|
|
RelayConfig config,
|
|
Community? community,
|
|
String? memberPubkey,
|
|
) =>
|
|
session.status == SessionStatus.connected &&
|
|
community != null &&
|
|
config.nsec != null &&
|
|
config.nsec!.isNotEmpty &&
|
|
memberPubkey != null &&
|
|
memberPubkey.isNotEmpty;
|
|
|
|
static Future<BuzzPushEndpointGrant> _publish(
|
|
WidgetRef ref,
|
|
RelayConfig config,
|
|
Community community,
|
|
String memberPubkey,
|
|
SignedEventRelay relay,
|
|
) async {
|
|
final state = community.pushSubscriptionState;
|
|
final desired = state.desired;
|
|
final descriptor = await ref.read(buzzPushDescriptorFetcherProvider)(
|
|
config.baseUrl,
|
|
);
|
|
validateBuzzPushChannelGrammar(descriptor, desired);
|
|
final grant = await enrollBuzzPush(
|
|
config.wsUrl,
|
|
Env.pushGatewayUrl,
|
|
communitiesForSnapshotRefresh:
|
|
ref.read(communityListProvider).value ?? [community],
|
|
);
|
|
// Relay lease replacement and gateway delegation are independent state
|
|
// machines. Subscription changes advance only the kind-30350 generation;
|
|
// the opaque grant remains reusable until its own authority changes.
|
|
final notifier = ref.read(communityListProvider.notifier);
|
|
await publishBuzzPushLeaseRecoverably(
|
|
reserveGeneration: () =>
|
|
notifier.reservePushLeaseGeneration(community.id),
|
|
publish: (leaseGeneration) => publishBuzzDevPushLeaseThroughRelay(
|
|
grant: grant,
|
|
leaseInstallationId: community.pushLeaseInstallationId,
|
|
leaseGeneration: leaseGeneration,
|
|
descriptor: descriptor,
|
|
nsec: config.nsec!,
|
|
memberPubkey: memberPubkey,
|
|
subscriptions: desired,
|
|
relay: relay,
|
|
),
|
|
markAccepted: (leaseGeneration) => notifier.markPushLeaseAccepted(
|
|
community.id,
|
|
subscriptions: desired,
|
|
generation: leaseGeneration,
|
|
),
|
|
);
|
|
return grant;
|
|
}
|
|
}
|
|
|
|
void _runRevocationOutbox(Future<void> Function() operation) {
|
|
unawaited(
|
|
operation().catchError((Object error, StackTrace stackTrace) {
|
|
reportPushLeaseCleanupError(error, stackTrace);
|
|
}),
|
|
);
|
|
}
|