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;