Skip to main content

decdn_cli/commands/
interrupt.rs

1//! Graceful Ctrl-C for `decdn get`, `decdn pool open` and `decdn pool top-up`.
2//!
3//! The first Ctrl-C resolves `Interrupt::wait`: the command stops its
4//! transfers, writes what it must keep (the bundle skip-cache, the progress
5//! indicators' cleanup), and returns [`Interrupted`], which exits with status
6//! 130. A second Ctrl-C, or one that arrives after the command stops watching,
7//! exits at once with the same status.
8//!
9//! Stopping mid-transfer loses nothing already paid for: every fetch resumes
10//! from its on-disk `.partial` on the next run. A `decdn get` run whose
11//! destination is relative keeps that state under the current directory, so
12//! its stop line names the directory to run the command again from
13//! ([`ResumeFrom`]). Stopping an `openPool` or a `topUp` before its record
14//! commits does not cancel the transaction, so the command names it
15//! (`name_held_escrows`, and the immediate exit) instead of telling the user to
16//! run it again: a re-run before it confirms escrows a second time. An escrow
17//! that mined with no local record crediting it is named the same way: a re-run
18//! reads the uncredited row and escrows again.
19
20use std::path::{Path, PathBuf};
21
22use decdn_client::GaveUp;
23use decdn_client::buyer_pool::{
24    EscrowUnconfirmed, OpenUnconfirmed, TopUpUnconfirmed, UnconfirmedEscrows, UntrackedEscrow,
25};
26
27/// The error a command returns when Ctrl-C stopped it. `main` prints
28/// [`stop_message`] and exits with status 130 (128 + SIGINT), the shell
29/// convention for an interrupted command.
30#[derive(Debug)]
31pub struct Interrupted;
32
33impl std::fmt::Display for Interrupted {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.write_str("interrupted")
36    }
37}
38
39impl std::error::Error for Interrupted {}
40
41/// The directory a stopped command must run again from to resume: the one its
42/// relative destination, and so its resume state, resolves against (the
43/// `.partial` files, and a bundle's `.decdn-partial/` and `.decdn-bundles/`).
44/// Attached to an [`Interrupted`] or a [`GaveUp`], so [`stop_message`] names it.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ResumeFrom(pub PathBuf);
47
48impl std::fmt::Display for ResumeFrom {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(f, "resume from {}", self.0.display())
51    }
52}
53
54impl std::error::Error for ResumeFrom {}
55
56/// Attach `dir` as the [`ResumeFrom`] directory to a stopped result: an
57/// [`Interrupted`] or a [`GaveUp`], both of which advise a re-run. Any other
58/// result passes through, since an error to fix is not resumed. The error keeps
59/// its own type underneath, so the stop keeps its exit status.
60pub(crate) fn name_resume_dir<T>(result: anyhow::Result<T>, dir: &Path) -> anyhow::Result<T> {
61    match result {
62        Err(err)
63            if err.downcast_ref::<Interrupted>().is_some()
64                || err.downcast_ref::<GaveUp>().is_some() =>
65        {
66            Err(err.context(ResumeFrom(dir.to_path_buf())))
67        }
68        other => other,
69    }
70}
71
72/// The `openPool` and `topUp` transactions a command ended with that someone
73/// must reconcile against the chain: those it did not see confirm, which may
74/// still escrow USDC, and those that escrowed with no local record crediting
75/// them. Displays one per line.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct UnreconciledEscrows {
78    /// The transactions no local record credits yet
79    /// ([`UnconfirmedEscrows::pending`]).
80    pending: Vec<EscrowUnconfirmed>,
81    /// The transactions that escrowed with no local record crediting them
82    /// ([`UnconfirmedEscrows::untracked`]). The handle releases each from the
83    /// pending list once it holds it.
84    untracked: Vec<UntrackedEscrow>,
85}
86
87impl std::fmt::Display for UnreconciledEscrows {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        let pending = self.pending.iter().map(ToString::to_string);
90        let untracked = self.untracked.iter().map(ToString::to_string);
91        for (i, escrow) in pending.chain(untracked).enumerate() {
92            if i > 0 {
93                f.write_str("\n")?;
94            }
95            f.write_str(&escrow)?;
96        }
97        Ok(())
98    }
99}
100
101impl std::error::Error for UnreconciledEscrows {}
102
103/// End a command with every `openPool` and `topUp` that `escrows` still holds
104/// named: [`name_unconfirmed_escrows`] over what the handle holds.
105pub(crate) fn name_held_escrows<T>(
106    result: anyhow::Result<T>,
107    escrows: &UnconfirmedEscrows,
108) -> anyhow::Result<T> {
109    name_unconfirmed_escrows(result, &escrows.pending(), &escrows.untracked())
110}
111
112/// End a command that may have dropped an `openPool` or a `topUp` before its
113/// receipt was read, or that holds one that escrowed with no local record.
114/// `escrows` are the transactions it did not see confirm
115/// ([`UnconfirmedEscrows::pending`]), and `untracked` those that escrowed
116/// untracked ([`UnconfirmedEscrows::untracked`]).
117///
118/// A pending transaction the error carries by type, or whose line the
119/// error's text holds, or an untracked one whose tx the error's text names, is
120/// not named twice. A stop (an [`Interrupted`]
121/// or a [`GaveUp`]) never counts as naming an escrow by its text:
122/// `main` prints only the stop's own line. With none left, `result` passes
123/// through. Otherwise they are attached to the result as
124/// [`UnreconciledEscrows`], so the command names each tx. An `Ok` becomes an
125/// error: the USDC may escrow, or has escrowed, with no local record, and only
126/// a non-zero exit makes sure someone reconciles it. An error keeps its own
127/// type underneath, so an [`Interrupted`] still exits 130.
128pub(crate) fn name_unconfirmed_escrows<T>(
129    result: anyhow::Result<T>,
130    escrows: &[EscrowUnconfirmed],
131    untracked: &[UntrackedEscrow],
132) -> anyhow::Result<T> {
133    let err = result.as_ref().err();
134    let carried = err.and_then(carried);
135    // The error that reported an escrow can sit inside one that hides it from
136    // a downcast, so it is looked for in the text too: a bundle pull folds
137    // every escrow but the first in as text.
138    let named = err
139        .filter(|err| err.downcast_ref::<Interrupted>().is_none())
140        .filter(|err| err.downcast_ref::<GaveUp>().is_none())
141        .map(|err| format!("{err:#}"));
142    let pending: Vec<EscrowUnconfirmed> = escrows
143        .iter()
144        .copied()
145        .filter(|escrow| Some(*escrow) != carried)
146        .filter(|escrow| {
147            named
148                .as_ref()
149                .is_none_or(|named| !named.contains(&escrow.to_string()))
150        })
151        .collect();
152    let untracked: Vec<UntrackedEscrow> = untracked
153        .iter()
154        .filter(|held| {
155            named
156                .as_ref()
157                .is_none_or(|named| !named.contains(&held.tx.to_string()))
158        })
159        .cloned()
160        .collect();
161    if pending.is_empty() && untracked.is_empty() {
162        return result;
163    }
164    let left = UnreconciledEscrows { pending, untracked };
165    match result {
166        Ok(_) => Err(anyhow::Error::new(left)),
167        Err(err) => Err(err.context(left)),
168    }
169}
170
171/// The unconfirmed `openPool` or `topUp` that `err` itself carries, from a
172/// submit lost in transport, a receipt read that failed or lapsed, or a
173/// refusal that names an earlier one.
174fn carried(err: &anyhow::Error) -> Option<EscrowUnconfirmed> {
175    err.downcast_ref::<OpenUnconfirmed>()
176        .copied()
177        .map(EscrowUnconfirmed::from)
178        .or_else(|| {
179            err.downcast_ref::<TopUpUnconfirmed>()
180                .copied()
181                .map(EscrowUnconfirmed::from)
182        })
183}
184
185/// The `openPool` and `topUp` transactions `err` names as possibly escrowing,
186/// or as escrowed with no local record, as text: the one it carries, then the
187/// [`UnreconciledEscrows`] attached to it.
188fn in_flight(err: &anyhow::Error) -> Option<String> {
189    let lines: Vec<String> = carried(err)
190        .map(|escrow| escrow.to_string())
191        .into_iter()
192        .chain(
193            err.downcast_ref::<UnreconciledEscrows>()
194                .map(ToString::to_string),
195        )
196        .collect();
197    (!lines.is_empty()).then(|| lines.join("\n"))
198}
199
200/// The line `main` prints for a command that stopped rather than failed: a
201/// Ctrl-C, or a give-up after the no-progress limit. `None` for any other
202/// error. The advice to run the command again, after a Ctrl-C or a give-up,
203/// names the [`ResumeFrom`] directory when the error carries one. An
204/// `openPool` or `topUp` in flight or untracked replaces that advice, because a
205/// re-run before it is reconciled escrows again.
206#[must_use]
207pub fn stop_message(err: &anyhow::Error) -> Option<String> {
208    let in_flight = in_flight(err);
209    if err.downcast_ref::<Interrupted>().is_some() {
210        return Some(match (in_flight, err.downcast_ref::<ResumeFrom>()) {
211            (Some(escrows), _) => format!("interrupted: {escrows}"),
212            (None, Some(ResumeFrom(dir))) => format!(
213                "interrupted; run the same command again from {} to resume",
214                dir.display()
215            ),
216            (None, None) => "interrupted; run the same command again to resume".to_owned(),
217        });
218    }
219    let gave_up = err.downcast_ref::<GaveUp>()?;
220    Some(match (in_flight, err.downcast_ref::<ResumeFrom>()) {
221        (Some(escrows), _) => format!("no progress for {}s: {escrows}", gave_up.idle.as_secs()),
222        (None, Some(ResumeFrom(dir))) => resume_from(gave_up, dir),
223        (None, None) => gave_up.to_string(),
224    })
225}
226
227/// The give-up line with its re-run advice naming `dir`: `no progress for 10m;
228/// rerun from <dir> to resume`. The line as [`GaveUp`] displays it when its
229/// text does not end in that advice.
230fn resume_from(gave_up: &GaveUp, dir: &Path) -> String {
231    let line = gave_up.to_string();
232    match line.strip_suffix("; rerun to resume") {
233        Some(head) => format!("{head}; rerun from {} to resume", dir.display()),
234        None => line,
235    }
236}
237
238/// The exit status for a command that stopped rather than failed:
239/// [`INTERRUPTED_EXIT`] for a Ctrl-C, and [`GAVE_UP_EXIT`] for a give-up.
240/// `None` for any other error, and for a give-up with an `openPool` or `topUp`
241/// in flight or untracked: `EX_TEMPFAIL` tells a wrapper that a re-run is
242/// safe, and that one is not.
243#[must_use]
244pub fn stop_exit(err: &anyhow::Error) -> Option<u8> {
245    if err.downcast_ref::<Interrupted>().is_some() {
246        return Some(INTERRUPTED_EXIT);
247    }
248    (err.downcast_ref::<GaveUp>().is_some() && in_flight(err).is_none()).then_some(GAVE_UP_EXIT)
249}
250
251/// The lines the immediate exit of a second Ctrl-C prints: one per
252/// `openPool` and `topUp` that `escrows` holds, pending or untracked.
253fn exit_lines(escrows: &UnconfirmedEscrows) -> Vec<String> {
254    let pending = escrows
255        .pending()
256        .into_iter()
257        .map(|escrow| escrow.to_string());
258    let untracked = escrows
259        .untracked()
260        .into_iter()
261        .map(|escrow| escrow.to_string());
262    pending
263        .chain(untracked)
264        .map(|escrow| format!("interrupted: {escrow}"))
265        .collect()
266}
267
268/// The exit status for an interrupted command: 128 + SIGINT.
269pub const INTERRUPTED_EXIT: u8 = 130;
270
271/// The exit status for a fetch that gave up after its no-progress limit:
272/// `EX_TEMPFAIL`, since a rerun resumes it.
273pub const GAVE_UP_EXIT: u8 = 75;
274
275/// A handle that resolves on the first Ctrl-C. Dropping it hands Ctrl-C back
276/// to an immediate exit. The immediate exit first names every `openPool` and
277/// `topUp` in the command's [`UnconfirmedEscrows`], pending or untracked: it
278/// skips the command's own return, which is where the first Ctrl-C names them.
279pub(crate) struct Interrupt {
280    /// Fires on the first Ctrl-C while this handle lives.
281    rx: tokio::sync::oneshot::Receiver<()>,
282    /// Whether `rx` has fired; a oneshot is not polled again after it resolves.
283    fired: bool,
284}
285
286impl Interrupt {
287    /// Start watching Ctrl-C for the rest of the process, for a command whose
288    /// `openPool` and `topUp` transactions `escrows` holds.
289    pub(crate) fn watch(escrows: UnconfirmedEscrows) -> Self {
290        let (tx, rx) = tokio::sync::oneshot::channel();
291        tokio::spawn(async move {
292            let mut tx = Some(tx);
293            // An `Err` means no handler could be installed, so SIGINT keeps its
294            // default action and there is nothing to watch.
295            while tokio::signal::ctrl_c().await.is_ok() {
296                if let Some(tx) = tx.take()
297                    && tx.send(()).is_ok()
298                {
299                    continue;
300                }
301                // Nothing graceful left to do: remove the tab indicator that
302                // `exit` would skip, name what may still escrow or escrowed
303                // untracked, then quit.
304                super::tab_progress::remove_now();
305                for line in exit_lines(&escrows) {
306                    eprintln!("{line}");
307                }
308                std::process::exit(i32::from(INTERRUPTED_EXIT));
309            }
310        });
311        Self { rx, fired: false }
312    }
313
314    /// A handle fired by the returned sender in place of a Ctrl-C.
315    #[cfg(test)]
316    pub(crate) fn manual() -> (tokio::sync::oneshot::Sender<()>, Self) {
317        let (tx, rx) = tokio::sync::oneshot::channel();
318        (tx, Self { rx, fired: false })
319    }
320
321    /// Resolve once the first Ctrl-C has arrived. Pends forever when no handler
322    /// could be installed.
323    pub(crate) async fn wait(&mut self) {
324        if self.fired {
325            return;
326        }
327        match (&mut self.rx).await {
328            Ok(()) => self.fired = true,
329            Err(_) => std::future::pending::<()>().await,
330        }
331    }
332}
333
334#[cfg(test)]
335#[allow(clippy::unwrap_used)]
336mod tests;