Skip to main content

nautilus_live/book/
recovery.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Recovery ownership and replacement attempts for one book.
17//!
18//! - [`BookRecoveryState`] admits one running recovery per book and cancels obsolete work.
19//! - [`BookRecovery`] runs replacement attempts until the adapter accepts a snapshot or cancels the
20//!   episode. A book never ends in a terminal failure state.
21//! - [`BookRecoveryOutcome`] carries pending, accepted, or rejected results from the adapter's
22//!   frame handling to the recovery runner.
23//!
24//! # Recovery Lifecycle
25//!
26//! The adapter claims an episode before starting work and supplies the replacement operation,
27//! retry classification, and error construction. Each attempt receives a child cancellation token
28//! and a closed snapshot gate. The adapter opens the gate after the intended connection confirms
29//! the write, then accepts a valid snapshot through [`BookRecovery::accept`]. Write completion
30//! alone never completes recovery.
31//!
32//! Attempts back off within a retry budget of eight attempts in 180 seconds. An exhausted budget,
33//! or an error the adapter classifies as not retryable, moves the episode to attempts at a jittered
34//! interval that doubles from one minute to fifteen minutes, which bounds subscription traffic for
35//! a book that keeps failing. Each such attempt is bounded by `max(snapshot_timeout, 60 seconds)`,
36//! so a stalled write or a disabled snapshot deadline cannot end the retries. The runner logs the
37//! transition as an error, each later failed attempt as a warning, and completion as info.
38//!
39//! # Adapters
40//!
41//! The adapter serializes claims and snapshot acceptance under its state lock or owning task. A
42//! reconnect keeps a running episode, whose replacement may be in flight, so the reconnect can
43//! neither replenish its budget nor abandon a write halfway; it wakes only a wait between attempts
44//! after the budget, as [`BookRecoveryState::reset_on_reconnect`] describes. Removing or resetting
45//! a [`BookRecoveryState`] cancels its episode; dropping an attempt cancels that attempt's child
46//! token.
47//!
48//! Task spawning, subscription correlation, and book cache updates remain adapter-owned. Claim an
49//! episode only once the replacement has what it needs, and cancel the episode if its task cannot
50//! start: a running episode without a task keeps its book owned with nothing running.
51
52use std::{fmt::Display, future::Future, sync::Arc};
53
54use nautilus_common::live::dst::time::{self, Duration};
55use nautilus_network::{
56    backoff::ExponentialBackoff,
57    retry::{RetryConfig, RetryManager},
58};
59use tokio_util::sync::CancellationToken;
60
61use super::snapshot::SnapshotGate;
62
63// Includes the initial attempt
64const ATTEMPTS_MAX: u32 = 8;
65const ELAPSED_MAX_MS: u64 = 180_000;
66const OPERATION_TIMEOUT_MS: Option<u64> = None;
67
68const RETRY_DELAY_INITIAL_MS: u64 = 1_000;
69const RETRY_DELAY_MAX_MS: u64 = 10_000;
70const RETRY_BACKOFF_FACTOR: f64 = 2.0;
71const RETRY_JITTER_MAX_MS: u64 = 1_000;
72const RETRY_FIRST_IMMEDIATE: bool = true;
73
74// Attempts after the retry budget, until acceptance or cancellation
75const RETRY_DELAY_CEILING_INITIAL_MS: u64 = 60_000;
76const RETRY_DELAY_CEILING_MAX_MS: u64 = 900_000;
77const RETRY_JITTER_CEILING_MAX_MS: u64 = 5_000;
78
79/// Outcome published by an adapter's snapshot and rejection handling.
80#[derive(Debug, Clone)]
81pub enum BookRecoveryOutcome<E> {
82    Pending,
83    Accepted,
84    Rejected(E),
85}
86
87/// One recovery episode, running until a snapshot is accepted or the episode is cancelled.
88#[derive(Debug)]
89pub struct BookRecovery<E> {
90    pub cancellation: CancellationToken,
91    pub outcome: tokio::sync::watch::Sender<BookRecoveryOutcome<E>>,
92    pub gate: SnapshotGate,
93    reconnected: tokio::sync::Notify,
94}
95
96impl<E: Clone> Default for BookRecovery<E> {
97    fn default() -> Self {
98        Self {
99            cancellation: CancellationToken::new(),
100            outcome: tokio::sync::watch::channel(BookRecoveryOutcome::Pending).0,
101            gate: SnapshotGate::default(),
102            reconnected: tokio::sync::Notify::new(),
103        }
104    }
105}
106
107impl<E: Clone> BookRecovery<E> {
108    /// Closes snapshot acceptance, returning `false` if this episode has already ended.
109    #[must_use]
110    pub fn begin_replacement(&self) -> bool {
111        let mut gate = self.gate.lock();
112
113        if self.cancellation.is_cancelled() || self.is_accepted() {
114            return false;
115        }
116
117        gate.close();
118        true
119    }
120
121    /// Returns whether the adapter has accepted a snapshot for this episode.
122    #[must_use]
123    pub fn is_accepted(&self) -> bool {
124        matches!(*self.outcome.borrow(), BookRecoveryOutcome::Accepted)
125    }
126
127    /// Returns whether this episode still owns its book: not accepted and not cancelled.
128    #[must_use]
129    pub fn is_running(&self) -> bool {
130        !self.is_accepted() && !self.cancellation.is_cancelled()
131    }
132
133    /// Returns `true` when the gate permits accepting a snapshot for this episode.
134    pub fn accept(&self) -> bool {
135        let gate = self.gate.lock();
136        if gate.is_closed() || (self.cancellation.is_cancelled() && !self.is_accepted()) {
137            return false;
138        }
139
140        self.outcome.send_replace(BookRecoveryOutcome::Accepted);
141        true
142    }
143}
144
145impl<E: Clone + std::error::Error> BookRecovery<E> {
146    /// Replaces the subscription and waits for a snapshot until one is accepted or the episode is
147    /// cancelled.
148    ///
149    /// Each send receives a child cancellation token. Dropping an attempt cancels queued
150    /// transport work. Keeping this future alive across reconnects preserves the retry budget.
151    /// A zero snapshot timeout disables only the individual snapshot deadline: the budget still
152    /// ends a pending attempt, and each attempt after it is bounded by
153    /// `max(snapshot_timeout, 60 seconds)`. Errors that `should_retry` rejects skip the rest of
154    /// the budget.
155    #[allow(
156        clippy::missing_panics_doc,
157        reason = "the ceiling backoff configuration is static and valid"
158    )]
159    pub async fn run<F, Fut>(
160        &self,
161        book: impl Display,
162        snapshot_timeout: Duration,
163        replace: F,
164        should_retry: impl Fn(&E) -> bool,
165        create_error: impl Fn(String) -> E,
166        timeout_error: impl Fn() -> E,
167    ) where
168        F: Fn(CancellationToken, SnapshotGate) -> Fut,
169        Fut: Future<Output = Result<(), E>>,
170    {
171        let attempt = || self.attempt(snapshot_timeout, &replace, &create_error, &timeout_error);
172
173        let budget = RetryManager::<E>::new(RetryConfig {
174            max_retries: ATTEMPTS_MAX - 1,
175            initial_delay_ms: RETRY_DELAY_INITIAL_MS,
176            max_delay_ms: RETRY_DELAY_MAX_MS,
177            backoff_factor: RETRY_BACKOFF_FACTOR,
178            jitter_ms: RETRY_JITTER_MAX_MS,
179            immediate_first: RETRY_FIRST_IMMEDIATE,
180            operation_timeout_ms: OPERATION_TIMEOUT_MS,
181            max_elapsed_ms: Some(ELAPSED_MAX_MS),
182        });
183
184        let result = budget
185            .invocation("book recovery", attempt, should_retry, |e| {
186                create_error(e.to_string())
187            })
188            .cancellation_token(&self.cancellation)
189            .execute()
190            .await;
191
192        if let Err(e) = result {
193            if self.cancellation.is_cancelled() {
194                return;
195            }
196
197            log::error!(
198                "Book recovery for {book} did not complete within its retry budget; retrying at \
199                 intervals growing from {}s to {}s until a snapshot is accepted: {e}",
200                RETRY_DELAY_CEILING_INITIAL_MS / 1_000,
201                RETRY_DELAY_CEILING_MAX_MS / 1_000,
202            );
203
204            // Bounds a stalled write and a disabled snapshot deadline alike
205            let attempt_max =
206                snapshot_timeout.max(Duration::from_millis(RETRY_DELAY_CEILING_INITIAL_MS));
207
208            let attempts = async {
209                let mut backoff = ExponentialBackoff::new(
210                    Duration::from_millis(RETRY_DELAY_CEILING_INITIAL_MS),
211                    Duration::from_millis(RETRY_DELAY_CEILING_MAX_MS),
212                    RETRY_BACKOFF_FACTOR,
213                    RETRY_JITTER_CEILING_MAX_MS,
214                    false,
215                )
216                .expect("static ceiling backoff configuration is valid");
217
218                loop {
219                    // A reconnect ends the wait so the next attempt uses the new connection
220                    tokio::select! {
221                        biased;
222                        () = self.reconnected.notified() => {}
223                        () = time::sleep(backoff.next_duration()) => {}
224                    }
225
226                    let result = time::timeout(attempt_max, attempt())
227                        .await
228                        .unwrap_or_else(|_| Err(timeout_error()));
229
230                    match result {
231                        Ok(()) => return,
232                        Err(e) => log::warn!("Book recovery attempt for {book} failed: {e}"),
233                    }
234                }
235            };
236
237            let mut outcome = self.outcome.subscribe();
238
239            // A snapshot accepted between attempts ends the wait; no replacement write can be in
240            // flight then, since acceptance requires an open gate.
241            tokio::select! {
242                biased;
243                () = self.cancellation.cancelled() => return,
244                _ = outcome.wait_for(|o| matches!(o, BookRecoveryOutcome::Accepted)) => {}
245                () = attempts => {}
246            }
247        }
248
249        if self.is_accepted() {
250            log::info!("Book recovery completed for {book}");
251        }
252    }
253
254    async fn attempt<F, Fut>(
255        &self,
256        snapshot_timeout: Duration,
257        replace: &F,
258        create_error: &impl Fn(String) -> E,
259        timeout_error: &impl Fn() -> E,
260    ) -> Result<(), E>
261    where
262        F: Fn(CancellationToken, SnapshotGate) -> Fut,
263        Fut: Future<Output = Result<(), E>>,
264    {
265        let mut outcome = self.outcome.subscribe();
266
267        if !self.begin_replacement() {
268            return Ok(());
269        }
270
271        self.outcome.send_if_modified(|outcome| {
272            if matches!(outcome, BookRecoveryOutcome::Rejected(_)) {
273                *outcome = BookRecoveryOutcome::Pending;
274                true
275            } else {
276                false
277            }
278        });
279
280        let cancel = self.cancellation.child_token();
281        let _guard = cancel.clone().drop_guard();
282        replace(cancel, self.gate.clone()).await?;
283
284        let wait = async {
285            loop {
286                match outcome.borrow_and_update().clone() {
287                    BookRecoveryOutcome::Accepted => return Ok(()),
288                    BookRecoveryOutcome::Rejected(e) => return Err(e),
289                    BookRecoveryOutcome::Pending => {}
290                }
291
292                outcome
293                    .changed()
294                    .await
295                    .map_err(|e| create_error(e.to_string()))?;
296            }
297        };
298
299        if snapshot_timeout.is_zero() {
300            wait.await
301        } else {
302            time::timeout(snapshot_timeout, wait)
303                .await
304                .unwrap_or_else(|_| Err(timeout_error()))
305        }
306    }
307}
308
309/// Owns a book's recovery episode under its adapter's state lock.
310#[derive(Debug)]
311pub struct BookRecoveryState<E> {
312    recovery: Option<Arc<BookRecovery<E>>>,
313}
314
315impl<E> Default for BookRecoveryState<E> {
316    fn default() -> Self {
317        Self { recovery: None }
318    }
319}
320
321impl<E: Clone> BookRecoveryState<E> {
322    /// Claims one recovery episode, refusing while another is running.
323    pub fn claim(&mut self) -> Option<Arc<BookRecovery<E>>> {
324        if self.is_running() {
325            return None;
326        }
327
328        self.reset();
329        let recovery = Arc::new(BookRecovery::default());
330        self.recovery = Some(Arc::clone(&recovery));
331        Some(recovery)
332    }
333
334    /// Returns the current episode.
335    #[must_use]
336    pub fn current(&self) -> Option<&Arc<BookRecovery<E>>> {
337        self.recovery.as_ref()
338    }
339
340    /// Returns whether a running episode owns the book.
341    #[must_use]
342    pub fn is_running(&self) -> bool {
343        self.recovery.as_ref().is_some_and(|r| r.is_running())
344    }
345
346    /// Keeps a running episode, whose replacement may be in flight, and cancels any other so the
347    /// next claim starts fresh.
348    ///
349    /// The kept episode keeps its budget. The reconnect never interrupts an attempt or shortens a
350    /// backoff inside the budget. An episode waiting between attempts after its budget attempts
351    /// again at once; a reconnect during an attempt or before the budget runs out ends the next
352    /// such wait instead.
353    pub fn reset_on_reconnect(&mut self) {
354        if let Some(recovery) = self
355            .recovery
356            .as_ref()
357            .filter(|recovery| recovery.is_running())
358        {
359            recovery.reconnected.notify_one();
360        } else {
361            self.reset();
362        }
363    }
364
365    /// Cancels the current episode.
366    pub fn reset(&mut self) {
367        if let Some(recovery) = self.recovery.take() {
368            recovery.cancellation.cancel();
369        }
370    }
371}
372
373impl<E> Drop for BookRecoveryState<E> {
374    fn drop(&mut self) {
375        if let Some(recovery) = &self.recovery {
376            recovery.cancellation.cancel();
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use std::sync::atomic::{AtomicUsize, Ordering};
384
385    use nautilus_network::error::SendError;
386    use rstest::rstest;
387
388    use super::*;
389
390    #[rstest]
391    fn replacement_owner_cannot_accept_after_reset() {
392        let mut state = BookRecoveryState::<SendError>::default();
393        let old = state.claim().unwrap();
394        let duplicate = state.claim();
395        state.reset();
396        let current = state.claim().unwrap();
397
398        assert!(duplicate.is_none());
399        assert!(old.cancellation.is_cancelled());
400        assert!(!old.accept());
401        assert!(Arc::ptr_eq(state.current().unwrap(), &current));
402        assert!(current.accept());
403        assert!(!state.is_running());
404    }
405
406    #[rstest]
407    fn closed_gate_suppresses_snapshot_until_opened() {
408        let mut state = BookRecoveryState::<SendError>::default();
409        let recovery = state.claim().unwrap();
410        assert!(recovery.begin_replacement());
411
412        let while_closed = recovery.accept();
413        recovery.gate.open();
414        let after_open = recovery.accept();
415
416        assert!(!while_closed);
417        assert!(after_open);
418        assert!(state.claim().is_some());
419    }
420
421    #[rstest]
422    fn claim_replaces_cancelled_episode() {
423        let mut state = BookRecoveryState::<SendError>::default();
424        let first = state.claim().unwrap();
425        first.cancellation.cancel();
426
427        let second = state.claim().unwrap();
428
429        assert!(!Arc::ptr_eq(&first, &second));
430        assert!(state.is_running());
431    }
432
433    #[rstest]
434    fn reconnect_keeps_running_episode() {
435        let mut state = BookRecoveryState::<SendError>::default();
436        let recovery = state.claim().unwrap();
437        assert!(recovery.begin_replacement());
438
439        state.reset_on_reconnect();
440
441        assert!(Arc::ptr_eq(state.current().unwrap(), &recovery));
442        assert!(!recovery.cancellation.is_cancelled());
443    }
444
445    #[rstest]
446    fn reconnect_resets_cancelled_episode() {
447        let mut state = BookRecoveryState::<SendError>::default();
448        let recovery = state.claim().unwrap();
449        recovery.cancellation.cancel();
450
451        state.reset_on_reconnect();
452
453        assert!(state.current().is_none());
454    }
455
456    #[rstest]
457    fn reconnect_resets_accepted_episode() {
458        let mut state = BookRecoveryState::<SendError>::default();
459        let recovery = state.claim().unwrap();
460        assert!(recovery.accept());
461
462        state.reset_on_reconnect();
463
464        assert!(state.current().is_none());
465    }
466
467    #[tokio::test(start_paused = true)]
468    async fn missing_snapshots_spend_budget_then_retry_at_ceiling() {
469        let recovery = BookRecovery::<SendError>::default();
470        let attempts = AtomicUsize::new(0);
471        let started = time::Instant::now();
472
473        recovery
474            .run(
475                "BOOK",
476                Duration::from_secs(1),
477                |_, gate| {
478                    let attempt = attempts.fetch_add(1, Ordering::SeqCst) + 1;
479                    gate.open();
480
481                    // The tenth attempt is the second at the ceiling interval
482                    if attempt == 10 {
483                        assert!(recovery.accept());
484                    }
485
486                    async { Ok(()) }
487                },
488                |_| true,
489                SendError::BrokenPipe,
490                || SendError::Timeout,
491            )
492            .await;
493
494        assert_eq!(attempts.load(Ordering::SeqCst), 10);
495        assert!(recovery.is_accepted());
496        assert!(started.elapsed() >= Duration::from_secs(180));
497    }
498
499    #[tokio::test(start_paused = true)]
500    async fn elapsed_budget_cancels_pending_send_and_continues() {
501        let recovery = BookRecovery::<SendError>::default();
502        let attempts = AtomicUsize::new(0);
503        let child = parking_lot::Mutex::new(None);
504
505        let result = time::timeout(
506            Duration::from_secs(200),
507            recovery.run(
508                "BOOK",
509                Duration::from_secs(1),
510                |cancel, _| {
511                    attempts.fetch_add(1, Ordering::SeqCst);
512                    *child.lock() = Some(cancel);
513                    std::future::pending::<Result<(), SendError>>()
514                },
515                |_| true,
516                SendError::BrokenPipe,
517                || SendError::Timeout,
518            ),
519        )
520        .await;
521
522        assert!(result.is_err());
523        assert_eq!(attempts.load(Ordering::SeqCst), 1);
524        assert!(recovery.is_running());
525        assert!(child.lock().as_ref().unwrap().is_cancelled());
526    }
527
528    #[tokio::test(start_paused = true)]
529    async fn non_retryable_error_moves_to_ceiling() {
530        let recovery = BookRecovery::<SendError>::default();
531        let attempts = AtomicUsize::new(0);
532
533        let result = time::timeout(
534            Duration::from_secs(59),
535            recovery.run(
536                "BOOK",
537                Duration::from_secs(1),
538                |_, _| {
539                    attempts.fetch_add(1, Ordering::SeqCst);
540                    async { Err(SendError::Closed) }
541                },
542                |_| false,
543                SendError::BrokenPipe,
544                || SendError::Timeout,
545            ),
546        )
547        .await;
548
549        assert!(result.is_err());
550        assert_eq!(attempts.load(Ordering::SeqCst), 1);
551        assert!(recovery.is_running());
552    }
553
554    // Neither a disabled snapshot deadline nor a write that never completes may end the retries
555    #[rstest]
556    #[case::disabled_snapshot_deadline(Duration::ZERO, false)]
557    #[case::stalled_write(Duration::from_secs(10), true)]
558    #[tokio::test(start_paused = true)]
559    async fn ceiling_attempts_are_bounded_and_continue(
560        #[case] snapshot_timeout: Duration,
561        #[case] stall_write: bool,
562    ) {
563        let recovery = BookRecovery::<SendError>::default();
564        let attempts = AtomicUsize::new(0);
565
566        let result = time::timeout(
567            Duration::from_secs(700),
568            recovery.run(
569                "BOOK",
570                snapshot_timeout,
571                |_, gate| {
572                    attempts.fetch_add(1, Ordering::SeqCst);
573                    let stall = stall_write;
574                    async move {
575                        if stall {
576                            std::future::pending::<()>().await;
577                        }
578
579                        gate.open();
580                        Ok(())
581                    }
582                },
583                |_| true,
584                SendError::BrokenPipe,
585                || SendError::Timeout,
586            ),
587        )
588        .await;
589
590        // The budget ends at 180s; ceiling attempts start near 240s and 420s, each bounded
591        assert!(result.is_err());
592        assert_eq!(attempts.load(Ordering::SeqCst), 3);
593        assert!(recovery.is_running());
594    }
595
596    #[tokio::test(start_paused = true)]
597    async fn snapshot_between_ceiling_attempts_completes_run() {
598        let recovery = BookRecovery::<SendError>::default();
599        let attempts = AtomicUsize::new(0);
600        let started = time::Instant::now();
601
602        let run = recovery.run(
603            "BOOK",
604            Duration::from_secs(1),
605            |_, gate| {
606                attempts.fetch_add(1, Ordering::SeqCst);
607                gate.open();
608                async { Err(SendError::Closed) }
609            },
610            |_| false,
611            SendError::BrokenPipe,
612            || SendError::Timeout,
613        );
614
615        let late_snapshot = async {
616            time::sleep(Duration::from_secs(10)).await;
617            assert!(recovery.accept());
618        };
619
620        tokio::join!(run, late_snapshot);
621
622        assert_eq!(attempts.load(Ordering::SeqCst), 1);
623        assert!(recovery.is_accepted());
624        assert_eq!(started.elapsed(), Duration::from_secs(10));
625    }
626
627    // A reconnect ends the wait at the ceiling so the kept episode retries on the new connection
628    #[tokio::test(start_paused = true)]
629    async fn reconnect_wakes_episode_waiting_at_ceiling() {
630        let mut state = BookRecoveryState::<SendError>::default();
631        let recovery = state.claim().unwrap();
632        let attempts = AtomicUsize::new(0);
633
634        let run = recovery.run(
635            "BOOK",
636            Duration::from_secs(1),
637            |_, _| {
638                attempts.fetch_add(1, Ordering::SeqCst);
639                async { Err(SendError::Closed) }
640            },
641            |_| false,
642            SendError::BrokenPipe,
643            || SendError::Timeout,
644        );
645
646        let reconnect = async {
647            time::sleep(Duration::from_secs(10)).await;
648            let before = attempts.load(Ordering::SeqCst);
649            state.reset_on_reconnect();
650            time::sleep(Duration::from_secs(1)).await;
651            let after = attempts.load(Ordering::SeqCst);
652            recovery.cancellation.cancel();
653            (before, after)
654        };
655
656        let ((), (before, after)) = tokio::join!(run, reconnect);
657
658        assert_eq!(before, 1);
659        assert_eq!(after, 2);
660        assert!(Arc::ptr_eq(state.current().unwrap(), &recovery));
661    }
662
663    #[tokio::test(start_paused = true)]
664    async fn cancellation_at_ceiling_ends_run() {
665        let recovery = BookRecovery::<SendError>::default();
666        let attempts = AtomicUsize::new(0);
667
668        let run = recovery.run(
669            "BOOK",
670            Duration::from_secs(1),
671            |_, _| {
672                attempts.fetch_add(1, Ordering::SeqCst);
673                async { Err(SendError::Closed) }
674            },
675            |_| false,
676            SendError::BrokenPipe,
677            || SendError::Timeout,
678        );
679
680        let cancel = async {
681            time::sleep(Duration::from_secs(30)).await;
682            recovery.cancellation.cancel();
683        };
684
685        tokio::join!(run, cancel);
686
687        assert_eq!(attempts.load(Ordering::SeqCst), 1);
688        assert!(!recovery.is_running());
689        assert!(!recovery.is_accepted());
690    }
691
692    #[tokio::test(start_paused = true)]
693    async fn snapshot_during_send_completes_without_retry() {
694        let recovery = BookRecovery::<SendError>::default();
695        let attempts = AtomicUsize::new(0);
696
697        recovery
698            .run(
699                "BOOK",
700                Duration::from_secs(1),
701                |_, gate| {
702                    attempts.fetch_add(1, Ordering::SeqCst);
703                    assert!(!recovery.accept());
704                    gate.open();
705                    assert!(recovery.accept());
706                    async { Ok(()) }
707                },
708                |_| true,
709                SendError::BrokenPipe,
710                || SendError::Timeout,
711            )
712            .await;
713
714        assert_eq!(attempts.load(Ordering::SeqCst), 1);
715        assert!(recovery.is_accepted());
716    }
717
718    #[tokio::test(start_paused = true)]
719    async fn dropping_owner_cancels_pending_attempt() {
720        let mut state = BookRecoveryState::<SendError>::default();
721        let recovery = state.claim().unwrap();
722        let child = parking_lot::Mutex::new(None);
723
724        let operation = recovery.run(
725            "BOOK",
726            Duration::ZERO,
727            |cancel, _| {
728                *child.lock() = Some(cancel);
729                std::future::pending::<Result<(), SendError>>()
730            },
731            |_| true,
732            SendError::BrokenPipe,
733            || SendError::Timeout,
734        );
735
736        tokio::pin!(operation);
737        tokio::select! {
738            () = &mut operation => panic!("unexpected completion"),
739            () = tokio::task::yield_now() => {},
740        }
741        drop(state);
742        operation.await;
743
744        assert!(child.lock().as_ref().unwrap().is_cancelled());
745    }
746}