Skip to main content

alloy_pubsub/
service.rs

1use crate::{
2    handle::ConnectionHandle,
3    ix::PubSubInstruction,
4    managers::{InFlight, RequestManager, SubscriptionManager},
5    PubSubConnect, PubSubFrontend, RawSubscription,
6};
7use alloy_json_rpc::{Id, PubSubItem, Request, Response, ResponsePayload, RpcError, SubId};
8use alloy_primitives::B256;
9use alloy_transport::{
10    utils::{to_json_raw_value, Spawnable},
11    TransportErrorKind, TransportResult,
12};
13use serde_json::value::RawValue;
14use std::time::Duration;
15use tokio::sync::{mpsc, oneshot};
16
17#[cfg(all(target_family = "wasm", target_os = "unknown"))]
18use wasmtimer::tokio::sleep;
19
20#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
21use tokio::time::sleep;
22
23const MAX_RECONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(30);
24
25/// The service contains the backend handle, a subscription manager, and the
26/// configuration details required to reconnect.
27#[derive(Debug)]
28pub(crate) struct PubSubService<T> {
29    /// The backend handle.
30    pub(crate) handle: ConnectionHandle,
31
32    /// The configuration details required to reconnect.
33    pub(crate) connector: T,
34
35    /// The inbound requests.
36    pub(crate) reqs: mpsc::UnboundedReceiver<PubSubInstruction>,
37
38    /// The subscription manager.
39    pub(crate) subs: SubscriptionManager,
40
41    /// The request manager.
42    pub(crate) in_flights: RequestManager,
43}
44
45impl<T: PubSubConnect> PubSubService<T> {
46    /// Create a new service from a connector.
47    pub(crate) async fn connect(connector: T) -> TransportResult<PubSubFrontend> {
48        let handle = connector.connect().await?;
49
50        let (tx, reqs) = mpsc::unbounded_channel();
51        let this = Self {
52            handle,
53            connector,
54            reqs,
55            subs: SubscriptionManager::default(),
56            in_flights: Default::default(),
57        };
58        this.spawn();
59        Ok(PubSubFrontend::new(tx))
60    }
61
62    /// Reconnect by dropping the backend and creating a new one.
63    async fn get_new_backend(&mut self) -> TransportResult<ConnectionHandle> {
64        let mut handle = self.connector.try_reconnect().await?;
65        std::mem::swap(&mut self.handle, &mut handle);
66        Ok(handle)
67    }
68
69    /// Reconnect the backend, re-issue pending requests, and re-start active
70    /// subscriptions.
71    async fn reconnect(&mut self) -> TransportResult<()> {
72        debug!("Reconnecting pubsub service backend");
73
74        let mut old_handle = self.get_new_backend().await?;
75
76        debug!("Draining old backend to_handle");
77
78        // Drain the old backend
79        while let Ok(item) = old_handle.from_socket.try_recv() {
80            self.handle_item(item)?;
81        }
82
83        old_handle.shutdown();
84
85        // Re-issue pending requests.
86        debug!(count = self.in_flights.len(), "Reissuing pending requests");
87        for (_, in_flight) in self.in_flights.iter() {
88            let msg = in_flight.request.serialized().to_owned();
89            self.dispatch_request(msg)?;
90        }
91
92        // Re-subscribe to all active subscriptions
93        debug!(count = self.subs.len(), "Re-starting active subscriptions");
94
95        // Drop all server IDs. We'll re-insert them as we get responses.
96        self.subs.drop_server_ids();
97
98        // Dispatch all subscription requests.
99        for (_, sub) in self.subs.iter() {
100            let req = sub.request().to_owned();
101            let (in_flight, _) = InFlight::new(req.clone(), sub.tx.receiver_count());
102            self.in_flights.insert(in_flight);
103
104            let msg = req.into_serialized();
105            self.dispatch_request(msg)?;
106        }
107
108        Ok(())
109    }
110
111    /// Dispatch a request to the socket.
112    fn dispatch_request(&self, brv: Box<RawValue>) -> TransportResult<()> {
113        self.handle.to_socket.send(brv).map(drop).map_err(|_| TransportErrorKind::backend_gone())
114    }
115
116    /// Service a request.
117    fn service_request(&mut self, in_flight: InFlight) -> TransportResult<()> {
118        let brv = in_flight.request();
119
120        self.dispatch_request(brv.serialized().to_owned())?;
121        self.in_flights.insert(in_flight);
122
123        Ok(())
124    }
125
126    /// Service a GetSub instruction.
127    ///
128    /// If the subscription exists, the waiter is sent `Some` broadcast receiver. If
129    /// the subscription does not exist, the waiter is sent `None`.
130    fn service_get_sub(&self, local_id: B256, tx: oneshot::Sender<Option<RawSubscription>>) {
131        let _ = tx.send(self.subs.get_subscription(local_id));
132    }
133
134    /// Service an unsubscribe instruction.
135    fn service_unsubscribe(&mut self, local_id: B256) -> TransportResult<()> {
136        if let Some(server_id) = self.subs.server_id_for(&local_id) {
137            // TODO: ideally we can send this with an unused id
138            let req = Request::new("eth_unsubscribe", Id::Number(1), [server_id]);
139            let brv = req.serialize().expect("no ser error").take_request();
140
141            self.dispatch_request(brv)?;
142        }
143        self.subs.remove_sub(local_id);
144        Ok(())
145    }
146
147    /// Service an instruction
148    fn service_ix(&mut self, ix: PubSubInstruction) -> TransportResult<()> {
149        trace!(?ix, "servicing instruction");
150        match ix {
151            PubSubInstruction::Request(in_flight) => self.service_request(in_flight),
152            PubSubInstruction::GetSub(alias, tx) => {
153                self.service_get_sub(alias, tx);
154                Ok(())
155            }
156            PubSubInstruction::Unsubscribe(alias) => self.service_unsubscribe(alias),
157        }
158    }
159
160    /// Handle an item from the backend.
161    fn handle_item(&mut self, item: PubSubItem) -> TransportResult<()> {
162        match item {
163            PubSubItem::Response(resp) => match self.in_flights.handle_response(resp) {
164                Some((server_id, in_flight)) => self.handle_sub_response(in_flight, server_id),
165                None => Ok(()),
166            },
167            PubSubItem::Notification(notification) => {
168                self.subs.notify(notification);
169                Ok(())
170            }
171        }
172    }
173
174    /// Rewrite the subscription id and insert into the subscriptions manager
175    fn handle_sub_response(
176        &mut self,
177        in_flight: InFlight,
178        server_id: SubId,
179    ) -> TransportResult<()> {
180        let request = in_flight.request;
181        let id = request.id().clone();
182
183        let sub = self.subs.upsert(request, server_id, in_flight.channel_size);
184
185        // Serialized B256 is always a valid serialized U256 too.
186        let ser_alias = to_json_raw_value(sub.local_id())?;
187
188        // We send back a success response with the new subscription ID.
189        // We don't care if the channel is dead.
190        let _ =
191            in_flight.tx.send(Ok(Response { id, payload: ResponsePayload::Success(ser_alias) }));
192
193        Ok(())
194    }
195
196    /// Attempt to reconnect with retries.
197    ///
198    /// Aborts immediately when a reconnect attempt returns a
199    /// [`TransportErrorKind::NonRetryable`] error so deterministic backend
200    /// failures (auth/protocol violations, malformed handshake, etc.) do not
201    /// burn the full retry budget.
202    async fn reconnect_with_retries(&mut self) -> TransportResult<()> {
203        let mut retry_count = 0;
204        let max_retries = self.handle.max_retries;
205        let interval = self.handle.retry_interval;
206        loop {
207            match self.reconnect().await {
208                Ok(()) => break Ok(()),
209                Err(e) => {
210                    if matches!(&e, RpcError::Transport(k) if k.is_non_retryable()) {
211                        error!("Reconnect aborted (non-retryable), shutting down: {e}");
212                        break Err(e);
213                    }
214                    retry_count += 1;
215                    if retry_count >= max_retries {
216                        error!("Reconnect failed after {max_retries} attempts, shutting down: {e}");
217                        break Err(e);
218                    }
219                    let retry_interval = reconnect_retry_interval(interval, retry_count);
220                    warn!(
221                        "Reconnection attempt {retry_count}/{max_retries} failed: {e}. \
222                         Retrying in {retry_interval:?}...",
223                    );
224                    sleep(retry_interval).await;
225                }
226            }
227        }
228    }
229
230    /// Spawn the service.
231    pub(crate) fn spawn(mut self) {
232        let fut = async move {
233            let result: TransportResult<()> = loop {
234                // We bias the loop so that we always handle new messages before
235                // reconnecting, and always reconnect before dispatching new
236                // requests.
237                tokio::select! {
238                    biased;
239
240                    item_opt = self.handle.from_socket.recv() => {
241                        if let Some(item) = item_opt {
242                            if let Err(e) = self.handle_item(item) {
243                                break Err(e)
244                            }
245                        } else {
246                            // The backend dropped its `to_frontend` sender.
247                            // It may have also signaled a typed error via the
248                            // `error` oneshot; drain it before reconnecting
249                            // so a non-retryable error short-circuits the loop.
250                            if let Ok(err) = self.handle.error.try_recv() {
251                                if matches!(&err, RpcError::Transport(k) if k.is_non_retryable()) {
252                                    error!(%err, "Pubsub service backend reported a non-retryable error, shutting down.");
253                                    break Err(err)
254                                }
255                                error!(%err, "Pubsub service backend error.");
256                            }
257                            if let Err(e) = self.reconnect_with_retries().await {
258                                break Err(e)
259                            }
260                        }
261                    }
262
263                    res = &mut self.handle.error => {
264                        // The backend signaled a terminal error. The carried
265                        // `TransportError` indicates whether it is recoverable.
266                        // If the sender was dropped without a value, fall back
267                        // to a generic backend-gone error.
268                        let err = res.unwrap_or_else(|_| TransportErrorKind::backend_gone());
269                        if matches!(&err, RpcError::Transport(k) if k.is_non_retryable()) {
270                            error!(%err, "Pubsub service backend reported a non-retryable error, shutting down.");
271                            break Err(err)
272                        }
273                        error!(%err, "Pubsub service backend error.");
274                        if let Err(e) = self.reconnect_with_retries().await {
275                            break Err(e)
276                        }
277                    }
278
279                    req_opt = self.reqs.recv() => {
280                        if let Some(req) = req_opt {
281                            if let Err(err) = self.service_ix(req) {
282                                if err
283                                    .as_transport_err()
284                                    .is_some_and(TransportErrorKind::is_backend_gone)
285                                {
286                                    if let Err(e) = self.reconnect_with_retries().await {
287                                        break Err(e)
288                                    }
289                                } else {
290                                    break Err(err)
291                                }
292                            }
293                        } else {
294                            info!("Pubsub service request channel closed. Shutting down.");
295                           break Ok(())
296                        }
297                    }
298                }
299            };
300
301            if let Err(err) = result {
302                error!(%err, "pubsub service reconnection error");
303            }
304        };
305        fut.spawn_task();
306    }
307}
308
309/// Returns the capped exponential backoff interval for a reconnect retry.
310///
311/// The configured retry interval is used as the base delay. Retry counts are 1-based, so the first
312/// failed attempt waits for the base interval, the second waits for twice the base interval, and so
313/// on. The delay is capped at [`MAX_RECONNECT_RETRY_INTERVAL`], unless the configured base interval
314/// is already higher, in which case the configured base interval is preserved.
315fn reconnect_retry_interval(base_interval: Duration, retry_count: u32) -> Duration {
316    let backoff_multiplier = 1u32.checked_shl(retry_count.saturating_sub(1)).unwrap_or(u32::MAX);
317    let max_interval = base_interval.max(MAX_RECONNECT_RETRY_INTERVAL);
318
319    base_interval.saturating_mul(backoff_multiplier).min(max_interval)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::ConnectionInterface;
326    use alloy_json_rpc::Request;
327    use std::{
328        sync::{
329            atomic::{AtomicUsize, Ordering},
330            Arc, Mutex,
331        },
332        time::Duration,
333    };
334    use tokio::time::timeout;
335
336    #[derive(Clone, Debug, Default)]
337    struct MockConnect(Arc<Mutex<Option<ConnectionHandle>>>);
338
339    impl PubSubConnect for MockConnect {
340        fn is_local(&self) -> bool {
341            true
342        }
343
344        async fn connect(&self) -> TransportResult<ConnectionHandle> {
345            Err(TransportErrorKind::custom_str("connect is not used in this test"))
346        }
347
348        async fn try_reconnect(&self) -> TransportResult<ConnectionHandle> {
349            self.0
350                .lock()
351                .expect("poisoned mutex")
352                .take()
353                .ok_or_else(|| TransportErrorKind::custom_str("missing mock connection handle"))
354        }
355    }
356
357    /// Mock connector that counts every `try_reconnect` invocation and
358    /// optionally returns a queued [`ConnectionHandle`].
359    #[derive(Clone, Debug, Default)]
360    struct CountingConnect {
361        handle: Arc<Mutex<Option<ConnectionHandle>>>,
362        calls: Arc<AtomicUsize>,
363    }
364
365    impl CountingConnect {
366        fn with_handle(handle: ConnectionHandle) -> Self {
367            Self {
368                handle: Arc::new(Mutex::new(Some(handle))),
369                calls: Arc::new(AtomicUsize::new(0)),
370            }
371        }
372    }
373
374    impl PubSubConnect for CountingConnect {
375        fn is_local(&self) -> bool {
376            true
377        }
378
379        async fn connect(&self) -> TransportResult<ConnectionHandle> {
380            Err(TransportErrorKind::custom_str("connect is not used in this test"))
381        }
382
383        async fn try_reconnect(&self) -> TransportResult<ConnectionHandle> {
384            self.calls.fetch_add(1, Ordering::SeqCst);
385            self.handle
386                .lock()
387                .expect("poisoned mutex")
388                .take()
389                .ok_or_else(|| TransportErrorKind::custom_str("no more handles"))
390        }
391    }
392
393    /// Returns a non-retryable error and counts `try_reconnect` calls.
394    #[derive(Clone, Debug, Default)]
395    struct NonRetryableConnect(Arc<AtomicUsize>);
396
397    impl PubSubConnect for NonRetryableConnect {
398        fn is_local(&self) -> bool {
399            true
400        }
401
402        async fn connect(&self) -> TransportResult<ConnectionHandle> {
403            Err(TransportErrorKind::non_retryable_str("non-retryable test failure"))
404        }
405
406        async fn try_reconnect(&self) -> TransportResult<ConnectionHandle> {
407            self.0.fetch_add(1, Ordering::SeqCst);
408            Err(TransportErrorKind::non_retryable_str("non-retryable test failure"))
409        }
410    }
411
412    #[test]
413    fn reconnect_retry_interval_uses_capped_exponential_backoff() {
414        let base = Duration::from_secs(1);
415
416        assert_eq!(reconnect_retry_interval(base, 1), Duration::from_secs(1));
417        assert_eq!(reconnect_retry_interval(base, 2), Duration::from_secs(2));
418        assert_eq!(reconnect_retry_interval(base, 3), Duration::from_secs(4));
419        assert_eq!(reconnect_retry_interval(base, 6), Duration::from_secs(30));
420    }
421
422    #[test]
423    fn reconnect_retry_interval_uses_configured_base_interval() {
424        let base = Duration::from_millis(1);
425
426        assert_eq!(reconnect_retry_interval(base, 1), Duration::from_millis(1));
427        assert_eq!(reconnect_retry_interval(base, 2), Duration::from_millis(2));
428    }
429
430    #[test]
431    fn reconnect_retry_interval_does_not_shorten_base_above_cap() {
432        let base = Duration::from_secs(60);
433
434        assert_eq!(reconnect_retry_interval(base, 1), Duration::from_secs(60));
435        assert_eq!(reconnect_retry_interval(base, 2), Duration::from_secs(60));
436    }
437
438    #[tokio::test]
439    async fn reconnects_after_request_dispatch_hits_backend_gone() {
440        let (dead_handle, dead_interface) = ConnectionHandle::new();
441        let ConnectionInterface { from_frontend, to_frontend, error, shutdown } = dead_interface;
442        drop(from_frontend);
443        let _keep_dead_backend_alive = (to_frontend, error, shutdown);
444
445        let (reconnected_handle, mut reconnected_interface) = ConnectionHandle::new();
446        let connector = MockConnect(Arc::new(Mutex::new(Some(reconnected_handle))));
447        let (tx, reqs) = mpsc::unbounded_channel();
448        let service = PubSubService {
449            handle: dead_handle,
450            connector,
451            reqs,
452            subs: SubscriptionManager::default(),
453            in_flights: RequestManager::default(),
454        };
455        service.spawn();
456
457        let first = Request::new("eth_blockNumber", Id::Number(1), ()).serialize().unwrap();
458        let (in_flight, rx) = InFlight::new(first, 16);
459        tx.send(PubSubInstruction::Request(in_flight)).unwrap();
460
461        timeout(Duration::from_secs(1), rx)
462            .await
463            .expect("failed request should resolve promptly")
464            .expect_err("raced request should be dropped when the backend is gone");
465
466        let second = Request::new("eth_chainId", Id::Number(2), ()).serialize().unwrap();
467        let expected = second.serialized().get().to_owned();
468        let (in_flight, _rx) = InFlight::new(second, 16);
469        tx.send(PubSubInstruction::Request(in_flight)).unwrap();
470
471        let dispatched =
472            timeout(Duration::from_secs(1), reconnected_interface.recv_from_frontend())
473                .await
474                .expect("request should be dispatched after reconnect")
475                .expect("new backend should receive the request");
476        assert_eq!(dispatched.get(), expected);
477    }
478
479    #[tokio::test]
480    async fn non_retryable_reconnect_error_short_circuits_retry_loop() {
481        let (dead_handle, dead_interface) = ConnectionHandle::new();
482        let ConnectionInterface { from_frontend, to_frontend, error, shutdown } = dead_interface;
483        drop(from_frontend);
484        let _keep_dead_backend_alive = (to_frontend, error, shutdown);
485
486        let connector = NonRetryableConnect::default();
487        let counter = connector.0.clone();
488        let (tx, reqs) = mpsc::unbounded_channel();
489        let service = PubSubService {
490            handle: dead_handle,
491            connector,
492            reqs,
493            subs: SubscriptionManager::default(),
494            in_flights: RequestManager::default(),
495        };
496        service.spawn();
497
498        let req = Request::new("eth_blockNumber", Id::Number(1), ()).serialize().unwrap();
499        let (in_flight, rx) = InFlight::new(req, 16);
500        tx.send(PubSubInstruction::Request(in_flight)).unwrap();
501
502        timeout(Duration::from_secs(1), rx)
503            .await
504            .expect("non-retryable reconnect should resolve promptly")
505            .expect_err("request should fail when backend is gone and reconnect aborts");
506
507        // Exactly one attempt, not `max_retries`.
508        assert_eq!(counter.load(Ordering::SeqCst), 1);
509    }
510
511    #[tokio::test]
512    async fn non_retryable_close_skips_reconnect_loop() {
513        // Backend is alive but emits a non-retryable error via the typed
514        // `close_with_transport_error` channel. The service must NOT call
515        // `try_reconnect` at all.
516        let (live_handle, live_interface) = ConnectionHandle::new();
517
518        // Provide a fresh handle that the connector *could* return, so that
519        // accidentally triggering `try_reconnect` would succeed and complete
520        // the reconnect path. We assert the call count to prove it didn't.
521        let (spare_handle, _spare_interface) = ConnectionHandle::new();
522        let connector = CountingConnect::with_handle(spare_handle);
523        let calls = connector.calls.clone();
524
525        let (_tx, reqs) = mpsc::unbounded_channel();
526        let service = PubSubService {
527            handle: live_handle,
528            connector,
529            reqs,
530            subs: SubscriptionManager::default(),
531            in_flights: RequestManager::default(),
532        };
533        service.spawn();
534
535        // Backend signals a deterministic, non-retryable failure.
536        live_interface.close_with_transport_error(TransportErrorKind::non_retryable_str(
537            "deterministic protocol failure",
538        ));
539
540        // Give the service a chance to act on the error.
541        tokio::time::sleep(Duration::from_millis(50)).await;
542
543        assert_eq!(
544            calls.load(Ordering::SeqCst),
545            0,
546            "non-retryable backend error must not trigger reconnect attempts"
547        );
548    }
549
550    #[tokio::test]
551    async fn default_close_with_error_still_reconnects() {
552        // Sanity check: the legacy `close_with_error()` path (which sends
553        // `BackendGone`) continues to trigger the reconnect loop.
554        let (live_handle, live_interface) = ConnectionHandle::new();
555
556        let (reconnected_handle, mut reconnected_interface) = ConnectionHandle::new();
557        let connector = CountingConnect::with_handle(reconnected_handle);
558        let calls = connector.calls.clone();
559
560        let (tx, reqs) = mpsc::unbounded_channel();
561        let service = PubSubService {
562            handle: live_handle,
563            connector,
564            reqs,
565            subs: SubscriptionManager::default(),
566            in_flights: RequestManager::default(),
567        };
568        service.spawn();
569
570        // Trigger the legacy close path.
571        live_interface.close_with_error();
572
573        // After reconnect, a freshly dispatched request must reach the new
574        // backend.
575        let req = Request::new("eth_chainId", Id::Number(1), ()).serialize().unwrap();
576        let expected = req.serialized().get().to_owned();
577        let (in_flight, _rx) = InFlight::new(req, 16);
578        tx.send(PubSubInstruction::Request(in_flight)).unwrap();
579
580        let dispatched =
581            timeout(Duration::from_secs(1), reconnected_interface.recv_from_frontend())
582                .await
583                .expect("request should be dispatched after reconnect")
584                .expect("new backend should receive the request");
585        assert_eq!(dispatched.get(), expected);
586
587        assert_eq!(
588            calls.load(Ordering::SeqCst),
589            1,
590            "default close_with_error should trigger exactly one reconnect"
591        );
592    }
593}