Skip to main content

bark/
daemon.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::time::Duration;
3
4use ark::rounds::RoundEvent;
5use futures::{FutureExt, StreamExt};
6use log::{info, trace, warn};
7#[cfg(not(feature = "wasm-web"))]
8use tokio::task::JoinHandle;
9use tokio_util::sync::CancellationToken;
10
11use crate::Wallet;
12use crate::utils::ReconnectBackoff;
13use crate::utils::time::sleep;
14
15
16
17/// A handle to a running background daemon
18#[cfg(not(feature = "wasm-web"))]
19pub struct DaemonHandle {
20	shutdown: CancellationToken,
21	jh: JoinHandle<()>,
22}
23
24/// A handle to a running background daemon for WASM
25#[cfg(feature = "wasm-web")]
26pub struct DaemonHandle {
27	shutdown: CancellationToken,
28}
29
30impl DaemonHandle {
31	/// Trigger the daemon process to stop
32	pub fn stop(&self) {
33		self.shutdown.cancel();
34	}
35
36	/// Stop the daemon process and wait for it to finish
37	pub async fn stop_wait(self) -> anyhow::Result<()> {
38		self.stop();
39		#[cfg(not(feature = "wasm-web"))]
40		self.jh.await?;
41		Ok(())
42	}
43}
44
45pub(crate) fn start_daemon(
46	wallet: Wallet,
47) -> DaemonHandle {
48	let shutdown = CancellationToken::new();
49	let proc = DaemonProcess::new(shutdown.clone(), wallet);
50
51	#[cfg(not(feature = "wasm-web"))]
52	{
53		let jh = crate::utils::spawn(proc.run());
54		DaemonHandle { shutdown, jh }
55	}
56	#[cfg(feature = "wasm-web")]
57	{
58		crate::utils::spawn(proc.run());
59		DaemonHandle { shutdown }
60	}
61}
62
63/// The daemon is responsible for running the wallet and performing the
64/// necessary actions to keep the wallet in a healthy state
65struct DaemonProcess {
66	shutdown: CancellationToken,
67
68	connected: AtomicBool,
69	wallet: Wallet,
70}
71
72impl DaemonProcess {
73	fn new(
74		shutdown: CancellationToken,
75		wallet: Wallet,
76	) -> DaemonProcess {
77		DaemonProcess {
78			connected: AtomicBool::new(false),
79			shutdown,
80			wallet,
81		}
82	}
83
84	fn sync_interval(&self) -> Duration {
85		Duration::from_secs(self.wallet.config().daemon_sync_interval_secs)
86	}
87
88	/// Recursively resubscribe to mailbox message stream by waiting and
89	/// calling [Wallet::subscribe_store_mailbox_messages] again until
90	/// the daemon is shutdown.
91	///
92	/// The mailbox stream is always-on and sets `connected` to `false`
93	/// when it breaks, so other processes can back off.
94	async fn run_mailbox_messages_process(&self) {
95		loop {
96			let shutdown = self.shutdown.clone();
97			if self.connected.load(Ordering::Relaxed) {
98				trace!("Daemon subscribing to mailbox message stream");
99				let r = self.wallet.subscribe_process_mailbox_messages(None, shutdown).await;
100				if let Err(e) = r {
101					warn!("An error occurred while processing mailbox messages: {e:#}");
102					self.connected.store(false, Ordering::Relaxed);
103				}
104			}
105
106			futures::select! {
107				_ = sleep(self.sync_interval()).fuse() => {},
108				_ = self.shutdown.cancelled().fuse() => {
109					info!("Shutdown signal received! Shutting mailbox messages process...");
110					break;
111				},
112			}
113		}
114	}
115
116	/// Sync pending boards, register new ones if needed
117	async fn run_boards_sync(&self) {
118		if let Err(e) = self.wallet.sync_pending_boards().await {
119			warn!("An error occured while syncing pending board: {e:#}");
120		}
121	}
122
123	/// Sync pending offboards, check for confirmations
124	async fn run_offboards_sync(&self) {
125		if let Err(e) = self.wallet.sync_pending_offboards().await {
126			warn!("An error occured while syncing pending offboards: {e:#}");
127		}
128	}
129
130	/// Sync pending rounds, check for confirmations and finalize VTXOs
131	async fn run_rounds_sync(&self) {
132		if let Err(e) = self.wallet.sync_pending_rounds().await {
133			warn!("An error occured while syncing pending rounds: {e:#}");
134		}
135	}
136
137	/// Update cached fee rates from the chain source
138	async fn run_fee_rate_update(&self) {
139		if let Err(e) = self.wallet.chain().update_fee_rates(self.wallet.config().fallback_fee_rate).await {
140			warn!("An error occured while updating fee rates: {e:#}");
141		}
142	}
143
144	/// Sync onchain wallet
145	async fn run_onchain_sync(&self) {
146		if let Err(e) = self.wallet.sync_onchain().await {
147			warn!("An error occured while syncing onchain: {e:#}");
148		}
149	}
150
151	/// Progress any ongoing unilateral exits and sync the exit statuses
152	async fn run_exits(&self) {
153		if self.wallet.inner.onchain.is_some() {
154			if let Err(e) = self.wallet.exit_mgr().progress_exits_with_cpfp(&self.wallet, None).await {
155				warn!("An error occurred while progressing exits: {:#}", e);
156			}
157		} else {
158			if let Err(e) = self.wallet.exit_mgr().progress_exits(&self.wallet).await {
159				warn!("An error occurred while progressing exits: {:#}", e);
160			}
161		}
162	}
163
164	async fn handle_round_event(&self, event: &RoundEvent) -> anyhow::Result<()> {
165		// Do a refresh if you need to
166		match &event {
167			&RoundEvent::Attempt(attempt) => {
168				if attempt.attempt_seq == 0 {
169					if let Err(err) = self.wallet.join_round_for_maintenance_refresh(attempt).await {
170						warn!("Failed to join round for maintenance refresh: {:#}", err);
171					}
172				};
173			},
174			_ => {},
175		};
176
177		self.wallet.progress_pending_rounds(Some(event)).await
178	}
179
180	/// Subscribe to the round event stream and process events
181	/// until it closes or the daemon shuts down.
182	///
183	/// `backoff` is reset whenever an event arrives, so a stream that stays
184	/// healthy for a while reconnects promptly after it eventually drops.
185	async fn process_round_event_stream(
186		&self,
187		backoff: &mut ReconnectBackoff,
188	) -> anyhow::Result<()> {
189		trace!("Daemon subscribing to round event stream");
190		let mut events = self.wallet.subscribe_round_events().await?;
191		trace!("Daemon connected to round event stream");
192
193		loop {
194			futures::select! {
195				res = events.next().fuse() => {
196					match res {
197						Some(Ok(event)) => {
198							backoff.reset();
199							if let Err(e) = self.handle_round_event(&event).await {
200								warn!("Error processing round event: {e:#}");
201							}
202						},
203						Some(Err(e)) => {
204							return Err(e.context("error on event stream"));
205						},
206						None => {
207							return Ok(());
208						},
209					}
210				},
211				_ = self.shutdown.cancelled().fuse() => {
212					info!("Shutdown signal received! Shutting round events stream...");
213					return Ok(());
214				},
215			}
216		}
217	}
218
219	/// Keep the round events subscription alive for the
220	/// lifetime of the daemon, reconnecting as needed.
221	async fn run_round_events_process(&self) {
222		let mut backoff = ReconnectBackoff::new();
223		loop {
224			if self.shutdown.is_cancelled() {
225				info!("Shutdown signal received! Shutting round events process...");
226				break;
227			}
228
229			match self.process_round_event_stream(&mut backoff).await {
230				Ok(()) => {},
231				// A tonic h2 stream reset is almost always a
232				// proxy- or server-side idle timeout rather than
233				// a real failure; resubscribe quietly.
234				Err(e) if crate::utils::is_h2_stream_error(&e) => {
235					trace!("Round events stream reset by server, reconnecting: {e:#}");
236				},
237				Err(e) => {
238					warn!("An error occured while processing pending rounds: {e:#}");
239				},
240			}
241
242			// Always back off before resubscribing. Otherwise a stream the
243			// server keeps closing quickly — including when it is rate-limiting
244			// us by resetting our streams — becomes a tight reconnect loop that
245			// floods the server with opened-then-reset streams. The backoff
246			// resets itself once a stream delivers an event, so healthy
247			// reconnects stay prompt.
248			futures::select! {
249				_ = backoff.wait().fuse() => {},
250				_ = self.shutdown.cancelled().fuse() => {
251					info!("Shutdown signal received! Shutting round events process...");
252					break;
253				},
254			}
255		}
256	}
257
258	/// Periodically try to reconnect when the server is not reachable.
259	///
260	/// Sets `connected` to `true` on success so the round-events
261	/// and mailbox streams start subscribing again.
262	async fn run_server_connection_check_process(&self) {
263		loop {
264			futures::select! {
265				_ = sleep(self.sync_interval()).fuse() => {},
266				_ = self.shutdown.cancelled().fuse() => {
267					info!("Shutdown signal received! Shutting server connection check process...");
268					break;
269				},
270			}
271
272			if self.connected.load(Ordering::Relaxed) {
273				continue;
274			}
275
276			let result = self.wallet.refresh_server().await;
277			if let Err(ref e) = result {
278				warn!("Ark server reconnect failed: {:#}", e);
279			} else {
280				info!("Ark server reconnected");
281				self.connected.store(true, Ordering::Relaxed);
282			}
283		}
284	}
285
286	async fn run_sync_processes(&self) {
287		// NB: tokio::time::interval needs Instant::now(), which panic on wasm
288		loop {
289			if self.connected.load(Ordering::Relaxed) {
290				self.run_fee_rate_update().await;
291				self.run_boards_sync().await;
292				self.run_offboards_sync().await;
293			}
294			self.run_onchain_sync().await;
295			self.run_rounds_sync().await;
296			self.run_exits().await;
297
298			futures::select! {
299				_ = sleep(self.sync_interval()).fuse() => {},
300				_ = self.shutdown.cancelled().fuse() => {
301					info!("Shutdown signal received! Shutting sync processes...");
302					break;
303				},
304			}
305		}
306	}
307
308	/// Run processes that only need to be run once on startup
309	async fn run_startup_tasks(&self) {
310		// Eagerly refresh the server connection before starting the other
311		// daemon tasks so they don't race the first connection check and
312		// skip their initial iteration with `connected = false` (which
313		// would delay mailbox subscription by `slow_interval`).
314		let result = self.wallet.refresh_server().await;
315		if let Err(ref e) = result {
316			warn!("Ark server refresh failed: {:#}", e);
317		}
318		let connected = self.wallet.inner.server.initialized();
319		self.connected.store(connected, Ordering::Relaxed);
320
321		if !self.wallet.config().daemon_manual_sync {
322			self.wallet.sync().await;
323		}
324	}
325
326	pub async fn run(self) {
327		info!("Starting daemon for wallet {}", self.wallet.fingerprint());
328
329		self.run_startup_tasks().await;
330		trace!("Daemon startup tasks complete, starting background processes");
331
332		if self.wallet.config().daemon_manual_sync {
333			// In manual-sync mode only the server connection heartbeat keeps
334			// running; everything else must be triggered via the REST API.
335			info!("Daemon running in manual-sync mode; background sync disabled");
336			let _ = self.run_server_connection_check_process().await;
337		} else {
338			#[cfg(not(feature = "wasm-web"))]
339			{
340				use std::sync::Arc;
341
342				// Each loop runs in its own tokio task so that a panic in one
343				// (e.g. from a crafted round proposal) cannot silently kill the
344				// others — in particular exit monitoring / CPFP fee-bumping.
345				let proc = Arc::new(self);
346				let p1 = Arc::clone(&proc);
347				let p2 = Arc::clone(&proc);
348				let p3 = Arc::clone(&proc);
349				let p4 = Arc::clone(&proc);
350				let _ = futures::join!(
351					supervised("server-connection", move || {
352						let p = Arc::clone(&p1);
353						async move { p.run_server_connection_check_process().await }
354					}),
355					supervised("round-events", move || {
356						let p = Arc::clone(&p2);
357						async move { p.run_round_events_process().await }
358					}),
359					supervised("sync", move || {
360						let p = Arc::clone(&p3);
361						async move { p.run_sync_processes().await }
362					}),
363					supervised("mailbox", move || {
364						let p = Arc::clone(&p4);
365						async move { p.run_mailbox_messages_process().await }
366					}),
367				);
368			}
369			#[cfg(feature = "wasm-web")]
370			{
371				let _ = futures::join!(
372					self.run_server_connection_check_process(),
373					self.run_round_events_process(),
374					self.run_sync_processes(),
375					self.run_mailbox_messages_process(),
376				);
377			}
378		}
379
380		info!("Daemon gracefully stopped");
381	}
382}
383
384/// Run `f` in its own [`tokio::spawn`] task, restarting it if it panics.
385///
386/// A clean return (shutdown signal) breaks the loop immediately.
387#[cfg(not(feature = "wasm-web"))]
388async fn supervised<F, Fut>(name: &'static str, f: F)
389where
390	F: Fn() -> Fut,
391	Fut: std::future::Future<Output = ()> + Send + 'static,
392{
393	loop {
394		match tokio::spawn(f()).await {
395			Ok(()) => break,
396			Err(e) => {
397				warn!("Daemon task '{}' terminated unexpectedly, restarting: {e}", name);
398				tokio::time::sleep(Duration::from_secs(1)).await;
399			},
400		}
401	}
402}