@@ -133,10 +133,39 @@ fn spawn_subscription_shutdown(client: Arc<Client>, cancellation_token: Cancella
133133 } ) ;
134134}
135135
136+ /// How a notification-pump attempt ended, so [`retry_until_cancelled`] can tell a
137+ /// clean stream end from a dispatcher-death with no receivers.
138+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
139+ enum PumpOutcome {
140+ /// The relay closed the stream (or shutdown was requested) with receivers
141+ /// still attached — reconnect promptly.
142+ StreamEnded ,
143+ /// The dispatcher fan-out has no receivers left. Re-subscribing immediately
144+ /// would hot-spin (the retry loop resets the backoff on a clean `Ok`), so this
145+ /// is surfaced as an error to trigger backoff instead.
146+ ReceiversGone ,
147+ }
148+
149+ impl PumpOutcome {
150+ /// Map the outcome onto the retry loop's `Result` contract: a clean stream end
151+ /// is `Ok` (backoff resets, reconnect promptly); no receivers is `Err` (backoff
152+ /// grows, so a persistent dispatcher-death backs off rather than re-subscribing
153+ /// every second). Never cancels the shared token either way.
154+ fn into_result ( self ) -> Result < ( ) > {
155+ match self {
156+ PumpOutcome :: StreamEnded => Ok ( ( ) ) ,
157+ PumpOutcome :: ReceiversGone => {
158+ bail ! ( "relay subscription dropped: no event receivers remain" )
159+ }
160+ }
161+ }
162+ }
163+
136164/// One subscription attempt: (re)subscribe and pump notifications until the relay
137165/// stream ends or the shared token is cancelled. Returns `Err` on a subscribe
138- /// failure so the retry loop can back off — but never cancels the shared token,
139- /// so a relay problem cannot tear down the HTTP server or the worker pools.
166+ /// failure *or* when the dispatcher fan-out has no receivers left, so the retry
167+ /// loop backs off instead of re-subscribing tightly — but never cancels the shared
168+ /// token, so a relay problem cannot tear down the HTTP server or the worker pools.
140169async fn run_subscription (
141170 client : & Client ,
142171 filters : & [ Filter ] ,
@@ -156,6 +185,10 @@ async fn run_subscription(
156185 // retry loop drives it back down when this attempt ends.
157186 mark_subscription_up ( relay_subscribed) ;
158187
188+ // Set when the pump leaves because the dispatcher fan-out has no receivers, so
189+ // we can back off instead of re-subscribing tightly (see [`PumpOutcome`]).
190+ let receivers_gone = AtomicBool :: new ( false ) ;
191+
159192 client
160193 . handle_notifications ( |notification| async {
161194 if cancellation_token. is_cancelled ( ) {
@@ -165,9 +198,10 @@ async fn run_subscription(
165198 if let RelayPoolNotification :: Event { event, .. } = notification {
166199 debug ! ( "Received event: {}" , event. id) ;
167200 if let Err ( e) = event_tx. send ( event) {
168- // No receivers left; leave the notification loop so the retry
169- // loop can re-establish later . Do NOT cancel the shared token.
201+ // No receivers left; leave the notification loop and flag it so
202+ // the retry loop backs off . Do NOT cancel the shared token.
170203 error ! ( "Failed to send nostr event: {}" , e) ;
204+ receivers_gone. store ( true , Ordering :: Relaxed ) ;
171205 return Ok ( true ) ;
172206 }
173207 }
@@ -177,7 +211,14 @@ async fn run_subscription(
177211 } )
178212 . await ?;
179213
180- Ok ( ( ) )
214+ // Distinguish a no-receivers exit (back off) from a clean stream end (reconnect
215+ // promptly). A cancelled token also lands here as `StreamEnded`; the retry loop
216+ // then observes the cancellation and returns without another attempt.
217+ if receivers_gone. load ( Ordering :: Relaxed ) {
218+ PumpOutcome :: ReceiversGone . into_result ( )
219+ } else {
220+ PumpOutcome :: StreamEnded . into_result ( )
221+ }
181222}
182223
183224/// Backoff bounds for re-establishing a dropped relay subscription.
@@ -400,4 +441,21 @@ mod tests {
400441 "a clean return must also flip the /health readiness flag to not-ready"
401442 ) ;
402443 }
444+
445+ /// The retry loop resets the backoff to 1s on `Ok(())` and grows it on `Err`.
446+ /// So the pump outcome must map a genuine clean stream end to `Ok` (reconnect
447+ /// promptly) but a no-receivers exit to `Err` — otherwise a dispatcher-death
448+ /// hot-spins re-subscribing every ~1s instead of backing off. This locks that
449+ /// contract.
450+ #[ test]
451+ fn pump_outcome_maps_receivers_gone_to_error_and_stream_end_to_ok ( ) {
452+ assert ! (
453+ PumpOutcome :: StreamEnded . into_result( ) . is_ok( ) ,
454+ "a clean stream end must reconnect promptly (Ok resets the backoff)"
455+ ) ;
456+ assert ! (
457+ PumpOutcome :: ReceiversGone . into_result( ) . is_err( ) ,
458+ "no receivers must back off (Err grows the backoff), not re-subscribe tightly"
459+ ) ;
460+ }
403461}
0 commit comments