matrix_sdk_common/task_monitor.rs
1// Copyright 2026 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
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//! This module provides a [`TaskMonitor`] for spawning and monitoring
16//! long-running background tasks. Tasks spawned through the monitor are
17//! monitored for panics, errors, and unexpected termination.
18//!
19//! ```no_run
20//! use matrix_sdk_common::task_monitor::TaskMonitor;
21//!
22//! let monitor = TaskMonitor::new();
23//!
24//! // Subscribe to failure notifications
25//! let mut failures = monitor.subscribe();
26//!
27//! // Spawn a monitored background task
28//! let handle = monitor.spawn_infinite_task("my_task", async {
29//! loop {
30//! // Do background work...
31//! matrix_sdk_common::sleep::sleep(std::time::Duration::from_secs(1))
32//! .await;
33//! }
34//! });
35//!
36//! // It's also possible to have the task be aborted safely (and without a report)
37//! // when the handle is dropped.
38//! let _handle = handle.abort_on_drop();
39//!
40//! // Listen for failures in another task
41//! // while let Ok(failure) = failures.recv().await {
42//! // eprintln!("Task {} failed: {:?}", failure.task.name, failure.reason);
43//! // }
44//! ```
45//!
46//! ## A word about unwind safety
47//!
48//! This assumes that all the code running inside the monitored tasks is [unwind
49//! safe](https://doc.rust-lang.org/std/panic/trait.UnwindSafe.html). The assumption is that these
50//! are long-running tasks that:
51//!
52//! - should not panic under normal operation,
53//! - will not be automatically restarted with state shared previously (they can
54//! be restarted, but in this case they have to be restarted with a clean
55//! state).
56//!
57//! In general, observers of the task monitor should consider any reported
58//! failure as fatal, and they may decide to report the error one way or another
59//! (e.g., logging, metrics) and subsequently crash the process to avoid running
60//! in a potentially corrupted state.
61//!
62//! ## WebAssembly (WASM) support
63//!
64//! Unfortunately, safe unwinding isn't supported on most WASM targets, as of
65//! 2026-01-28, so panics in monitored tasks cannot be caught and reported.
66//! Instead, a panic in a monitored task may throw a JS exception. The rest of
67//! the monitoring features (error reporting, early termination)
68//! is still functional, though.
69
70use std::{
71 any::Any,
72 collections::HashMap,
73 future::Future,
74 panic::AssertUnwindSafe,
75 sync::{
76 Arc,
77 atomic::{AtomicBool, AtomicU64, Ordering},
78 },
79};
80
81use futures_util::FutureExt;
82use tokio::sync::broadcast;
83use tracing::{Instrument, Span};
84
85use crate::{
86 SendOutsideWasm,
87 executor::{AbortHandle, spawn},
88 locks::RwLock,
89};
90
91/// Unique identifier for a background task.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
93pub struct TaskId(u64);
94
95impl TaskId {
96 /// Create a new unique task ID, by incrementing a global counter.
97 fn new() -> Self {
98 static NEXT_ID: AtomicU64 = AtomicU64::new(0);
99 Self(NEXT_ID.fetch_add(1, Ordering::SeqCst))
100 }
101}
102
103impl std::fmt::Display for TaskId {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 write!(f, "TaskId({})", self.0)
106 }
107}
108
109/// Metadata about a spawned background task.
110#[derive(Debug, Clone)]
111pub struct BackgroundTaskInfo {
112 /// Unique identifier for this task.
113 pub id: TaskId,
114
115 /// Human-readable name for the task, as defined when spawning it.
116 pub name: String,
117}
118
119/// Reason why a background task failed.
120#[derive(Debug, Clone)]
121#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
122pub enum BackgroundTaskFailureReason {
123 /// The task panicked.
124 Panic {
125 /// The panic message, if it could be extracted.
126 message: Option<String>,
127 /// Backtrace captured after the panic (if available).
128 panic_backtrace: Option<String>,
129 },
130
131 /// The task returned an error.
132 Error {
133 /// String representation of the error.
134 // TODO: consider storing a boxed error instead?
135 error: String,
136 },
137
138 /// The task ended unexpectedly (for tasks expected to run forever).
139 EarlyTermination,
140}
141
142/// A report of a background task failure.
143///
144/// This is sent through the broadcast channel when a monitored task fails.
145#[derive(Debug, Clone)]
146pub struct BackgroundTaskFailure {
147 /// Information about the task that failed.
148 pub task: BackgroundTaskInfo,
149
150 /// Why the task failed.
151 pub reason: BackgroundTaskFailureReason,
152}
153
154/// Internal entry for tracking an active task.
155#[derive(Debug)]
156struct ActiveTask {
157 /// The tokio's handle to preemptively abort the task.
158 // TODO: might be useful to abort on drop?
159 _abort_handle: AbortHandle,
160}
161
162/// Default capacity for the failure broadcast channel.
163///
164/// It doesn't have to be large, because it's expected that consumers of such a
165/// failure report would likely stop execution of the SDK or take immediate
166/// corrective action, and that failures should be rare.
167const FAILURE_CHANNEL_CAPACITY: usize = 8;
168
169/// A monitor for spawning and monitoring background tasks.
170///
171/// The [`TaskMonitor`] allows you to spawn background tasks that are
172/// automatically monitored for panics, errors, and unexpected termination. In
173/// such cases, a [`BackgroundTaskFailure`] is sent through a broadcast channel
174/// that subscribers can listen to.
175///
176/// # Example
177///
178/// ```no_run
179/// use matrix_sdk_common::task_monitor::TaskMonitor;
180///
181/// let monitor = TaskMonitor::new();
182///
183/// // Subscribe to failures
184/// let mut failures = monitor.subscribe();
185///
186/// // Spawn a task that runs indefinitely
187/// let _handle = monitor.spawn_infinite_task("worker", async {
188/// loop {
189/// // Do work...
190/// matrix_sdk_common::sleep::sleep(std::time::Duration::from_secs(1))
191/// .await;
192/// }
193/// });
194/// ```
195#[derive(Debug)]
196pub struct TaskMonitor {
197 /// Sender for failure notifications.
198 failure_sender: broadcast::Sender<BackgroundTaskFailure>,
199
200 /// Map of active tasks by ID.
201 active_task_handles: Arc<RwLock<HashMap<TaskId, ActiveTask>>>,
202}
203
204impl Default for TaskMonitor {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210impl TaskMonitor {
211 /// Create a new task monitor.
212 pub fn new() -> Self {
213 let (failure_sender, _) = broadcast::channel(FAILURE_CHANNEL_CAPACITY);
214 Self { failure_sender, active_task_handles: Default::default() }
215 }
216
217 /// Subscribe to failure notifications.
218 ///
219 /// Returns a broadcast receiver that will receive [`BackgroundTaskFailure`]
220 /// messages whenever a monitored task fails.
221 ///
222 /// Note: If the receiver falls behind, older messages may be dropped.
223 pub fn subscribe(&self) -> broadcast::Receiver<BackgroundTaskFailure> {
224 self.failure_sender.subscribe()
225 }
226
227 /// Spawn a background task that is expected to **run forever**.
228 ///
229 /// For one-off background tasks that are expected to complete successfully,
230 /// use [`Self::spawn_finite_task`] instead.
231 ///
232 /// If the task completes (whether successfully or by panicking), it will be
233 /// reported as a [`BackgroundTaskFailure`] report through the broadcast
234 /// channel.
235 ///
236 /// Use this for long-running tasks like event loops, sync tasks, or
237 /// background workers that should never complete under normal operation.
238 ///
239 /// # Arguments
240 ///
241 /// - `name` - A human-readable name for the task (for debugging purposes).
242 /// - `future` - The async task to run.
243 ///
244 /// # Returns
245 ///
246 /// A [`BackgroundTaskHandle`] that can be used to abort the task or check
247 /// if it has finished. This is the equivalent of tokio's `JoinHandle`.
248 pub fn spawn_infinite_task<F>(&self, name: impl Into<String>, future: F) -> BackgroundTaskHandle
249 where
250 F: Future<Output = ()> + SendOutsideWasm + 'static,
251 {
252 self.spawn_task_internal(name, future, true)
253 }
254
255 /// Spawn a background job that is expected to run once and complete
256 /// successfully in the background.
257 ///
258 /// For long-term background jobs that are expected to run forever, use
259 /// [`Self::spawn_infinite_task`] instead.
260 ///
261 /// If the task completes (by panicking), it will be reported as a
262 /// [`BackgroundTaskFailure`] report through the broadcast channel.
263 ///
264 /// Use this for one-shot background tasks that should complete under normal
265 /// operation.
266 ///
267 /// # Arguments
268 ///
269 /// - `name` - A human-readable name for the task (for debugging purposes).
270 /// - `future` - The async task to run.
271 ///
272 /// # Returns
273 ///
274 /// A [`BackgroundTaskHandle`] that can be used to abort the task or check
275 /// if it has finished. This is the equivalent of tokio's `JoinHandle`.
276 pub fn spawn_finite_task<F>(&self, name: impl Into<String>, future: F) -> BackgroundTaskHandle
277 where
278 F: Future<Output = ()> + SendOutsideWasm + 'static,
279 {
280 self.spawn_task_internal(name, future, false)
281 }
282
283 fn spawn_task_internal<F>(
284 &self,
285 name: impl Into<String>,
286 future: F,
287 runs_forever: bool,
288 ) -> BackgroundTaskHandle
289 where
290 F: Future<Output = ()> + SendOutsideWasm + 'static,
291 {
292 let name = name.into();
293 let task_id = TaskId::new();
294 let task_info = BackgroundTaskInfo { id: task_id, name };
295
296 let intentionally_aborted = Arc::new(AtomicBool::new(false));
297
298 let active_tasks = self.active_task_handles.clone();
299 let failure_sender = self.failure_sender.clone();
300 let aborted_flag = intentionally_aborted.clone();
301
302 let wrapped = async move {
303 // SAFETY: see module-level documentation about unwind safety.
304 let result = AssertUnwindSafe(future).catch_unwind().await;
305
306 // Remove the task from the list of active ones.
307 active_tasks.write().remove(&task_id);
308
309 // Don't report if intentionally aborted.
310 if aborted_flag.load(Ordering::Acquire) {
311 return;
312 }
313
314 let failure_reason = match result {
315 Ok(()) => {
316 if runs_forever {
317 // The background forever task ended, this is considered
318 // an early termination.
319 BackgroundTaskFailureReason::EarlyTermination
320 } else {
321 // The task ended successfully, no failure to report.
322 return;
323 }
324 }
325
326 Err(panic_payload) => BackgroundTaskFailureReason::Panic {
327 message: extract_panic_message(&panic_payload),
328 panic_backtrace: capture_backtrace(),
329 },
330 };
331
332 let failure = BackgroundTaskFailure { task: task_info, reason: failure_reason };
333
334 // Forward failure to observers (ignore if there's none).
335 let _ = failure_sender.send(failure);
336 }
337 .instrument(Span::current());
338
339 let join_handle = spawn(wrapped);
340 let abort_handle = join_handle.abort_handle();
341
342 // Register the task.
343 self.active_task_handles
344 .write()
345 .insert(task_id, ActiveTask { _abort_handle: abort_handle.clone() });
346
347 BackgroundTaskHandle { abort_on_drop: false, abort_handle, intentionally_aborted }
348 }
349
350 /// Spawn a background task that returns a `Result`.
351 ///
352 /// The task is monitored for panics and errors; see also
353 /// [`BackgroundTaskFailure`].
354 ///
355 /// If the task returns `Ok(())`, it is considered successful and no failure
356 /// is reported.
357 ///
358 /// # Arguments
359 ///
360 /// - `name` - A human-readable name for the task (for debugging purposes).
361 /// - `future` - The async task to run.
362 ///
363 /// # Returns
364 ///
365 /// A [`BackgroundTaskHandle`] that can be used to abort the task or check
366 /// if it has finished. This is the equivalent of tokio's `JoinHandle`.
367 pub fn spawn_fallible_task<F, E>(
368 &self,
369 name: impl Into<String>,
370 future: F,
371 ) -> BackgroundTaskHandle
372 where
373 F: Future<Output = Result<(), E>> + SendOutsideWasm + 'static,
374 E: std::error::Error + SendOutsideWasm + 'static,
375 {
376 let name = name.into();
377 let task_id = TaskId::new();
378 let task_info = BackgroundTaskInfo { id: task_id, name };
379
380 let intentionally_aborted = Arc::new(AtomicBool::new(false));
381
382 let active_tasks = self.active_task_handles.clone();
383 let failure_sender = self.failure_sender.clone();
384 let aborted_flag = intentionally_aborted.clone();
385
386 let wrapped = async move {
387 let result = AssertUnwindSafe(future).catch_unwind().await;
388
389 active_tasks.write().remove(&task_id);
390
391 // Don't report if intentionally aborted.
392 if aborted_flag.load(Ordering::Acquire) {
393 return;
394 }
395
396 let failure_reason = match result {
397 Ok(Ok(())) => {
398 // The task ended successfully, no failure to report.
399 return;
400 }
401
402 Ok(Err(e)) => BackgroundTaskFailureReason::Error { error: e.to_string() },
403
404 Err(panic_payload) => BackgroundTaskFailureReason::Panic {
405 message: extract_panic_message(&panic_payload),
406 panic_backtrace: capture_backtrace(),
407 },
408 };
409
410 // Send failure (ignore if no receivers).
411 let _ = failure_sender
412 .send(BackgroundTaskFailure { task: task_info, reason: failure_reason });
413 }
414 .instrument(Span::current());
415
416 let join_handle = spawn(wrapped);
417 let abort_handle = join_handle.abort_handle();
418
419 // Register the task.
420 self.active_task_handles
421 .write()
422 .insert(task_id, ActiveTask { _abort_handle: abort_handle.clone() });
423
424 BackgroundTaskHandle { abort_on_drop: false, abort_handle, intentionally_aborted }
425 }
426}
427
428/// A handle to a spawned background task.
429///
430/// This handle can be used to abort the task or check if it has finished. When
431/// aborted through this handle, the task will NOT be reported as a failure.
432#[derive(Debug)]
433pub struct BackgroundTaskHandle {
434 /// The underlying tokio's [`AbortHandle`].
435 abort_handle: AbortHandle,
436
437 /// Should the task be safely aborted on drop?
438 ///
439 /// This won't result in a failure report, as it's an intentional abort.
440 abort_on_drop: bool,
441
442 /// An additional flag to indicate if the task was intentionally aborted, so
443 /// we don't report it as a failure when that happens.
444 intentionally_aborted: Arc<AtomicBool>,
445}
446
447impl Drop for BackgroundTaskHandle {
448 fn drop(&mut self) {
449 if self.abort_on_drop {
450 self.abort();
451 }
452 }
453}
454
455impl BackgroundTaskHandle {
456 /// Configure the handle to abort the task when dropped.
457 ///
458 /// The task will be stopped and will NOT be reported as a failure (this is
459 /// considered intentional termination).
460 pub fn abort_on_drop(mut self) -> Self {
461 self.abort_on_drop = true;
462 self
463 }
464
465 /// Abort the task.
466 ///
467 /// The task will be stopped and will NOT be reported as a failure (this is
468 /// considered intentional termination).
469 pub fn abort(&self) {
470 // Note: ordering matters here, we set the flag before aborting
471 // otherwise there's a possible race condition where the abort() is
472 // observed before the flag is set, and the task monitor would consider
473 // this an unexpected termination.
474 self.intentionally_aborted.store(true, Ordering::Release);
475 self.abort_handle.abort();
476 }
477
478 /// Check if the task has finished.
479 ///
480 /// Returns `true` if the task completed, panicked, or was aborted on
481 /// non-wasm; on wasm, returns whether the task has been aborted only (due
482 /// to lack of better APIs).
483 pub fn is_finished(&self) -> bool {
484 #[cfg(not(target_family = "wasm"))]
485 {
486 self.abort_handle.is_finished()
487 }
488 #[cfg(target_family = "wasm")]
489 {
490 self.abort_handle.is_aborted()
491 }
492 }
493}
494
495/// Capture a backtrace at the current location.
496///
497/// Returns `None` if backtraces are not enabled or not available.
498#[cfg(not(target_family = "wasm"))]
499fn capture_backtrace() -> Option<String> {
500 use std::backtrace::{Backtrace, BacktraceStatus};
501
502 let bt = Backtrace::capture();
503 if bt.status() == BacktraceStatus::Captured { Some(bt.to_string()) } else { None }
504}
505
506/// Capture a backtrace - WASM version (backtraces not typically available).
507#[cfg(target_family = "wasm")]
508fn capture_backtrace() -> Option<String> {
509 None
510}
511
512/// Extract a message from a panic payload.
513fn extract_panic_message(payload: &Box<dyn Any + Send>) -> Option<String> {
514 if let Some(s) = payload.downcast_ref::<&str>() {
515 Some((*s).to_owned())
516 } else {
517 payload.downcast_ref::<String>().cloned()
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use std::{
524 sync::{
525 Arc,
526 atomic::{AtomicBool, Ordering},
527 },
528 time::Duration,
529 };
530
531 use assert_matches::assert_matches;
532 use matrix_sdk_test_macros::async_test;
533
534 use super::{BackgroundTaskFailureReason, TaskMonitor};
535 use crate::{sleep::sleep, timeout::timeout};
536
537 #[async_test]
538 async fn test_early_termination_is_reported() {
539 let monitor = TaskMonitor::new();
540 let mut failures = monitor.subscribe();
541
542 // Spawn a task that completes immediately.
543 let _handle = monitor.spawn_infinite_task("test_task", async {
544 // Completes immediately: this is an "early termination".
545 });
546
547 // Should receive an early termination failure.
548 let failure = timeout(failures.recv(), Duration::from_secs(1))
549 .await
550 .expect("timeout waiting for failure")
551 .expect("channel closed");
552
553 assert_eq!(failure.task.name, "test_task");
554 assert_matches!(failure.reason, BackgroundTaskFailureReason::EarlyTermination);
555 }
556
557 #[async_test]
558 #[cfg(not(target_family = "wasm"))] // Unfortunately, safe unwinding doesn't work on wasm.
559 async fn test_panic_is_captured() {
560 let monitor = TaskMonitor::new();
561 let mut failures = monitor.subscribe();
562
563 // Spawn a task that panics.
564 let _handle = monitor.spawn_infinite_task("panicking_task", async {
565 panic!("test panic message");
566 });
567
568 // Should receive a panic failure.
569 let failure = timeout(failures.recv(), Duration::from_secs(1))
570 .await
571 .expect("timeout waiting for failure")
572 .expect("channel closed");
573
574 assert_eq!(failure.task.name, "panicking_task");
575 assert_matches!(
576 failure.reason,
577 BackgroundTaskFailureReason::Panic { message, .. } => {
578 assert_eq!(message.as_deref(), Some("test panic message"));
579 }
580 );
581 }
582
583 #[async_test]
584 async fn test_error_is_captured() {
585 let monitor = TaskMonitor::new();
586 let mut failures = monitor.subscribe();
587
588 // Spawn a fallible task that returns an error.
589 let _handle = monitor.spawn_fallible_task("fallible_task", async {
590 Err::<(), _>(std::io::Error::other("test error message"))
591 });
592
593 // Should receive an error failure.
594 let failure = timeout(failures.recv(), Duration::from_secs(1))
595 .await
596 .expect("timeout waiting for failure")
597 .expect("channel closed");
598
599 assert_eq!(failure.task.name, "fallible_task");
600 assert_matches!(
601 failure.reason,
602 BackgroundTaskFailureReason::Error { error } => {
603 assert!(error.contains("test error message"));
604 }
605 );
606 }
607
608 #[async_test]
609 async fn test_successful_fallible_task_no_failure() {
610 let monitor = TaskMonitor::new();
611 let mut failures = monitor.subscribe();
612
613 // Spawn a fallible task that succeeds.
614 let _handle =
615 monitor.spawn_fallible_task("success_task", async { Ok::<(), std::io::Error>(()) });
616
617 // Should NOT receive any failure: use a short timeout.
618 let result = timeout(failures.recv(), Duration::from_millis(100)).await;
619 assert!(result.is_err(), "should timeout, no failure expected");
620 }
621
622 #[async_test]
623 async fn test_abort_does_not_report_failure() {
624 let monitor = TaskMonitor::new();
625 let mut failures = monitor.subscribe();
626
627 // Spawn a long-running task.
628 let handle = monitor.spawn_infinite_task("aborted_task", async {
629 loop {
630 sleep(Duration::from_secs(10)).await;
631 }
632 });
633
634 // Give the task time to start.
635 sleep(Duration::from_millis(10)).await;
636
637 // Abort it.
638 handle.abort();
639
640 // Should NOT receive a failure for intentional abort.
641 let result = timeout(failures.recv(), Duration::from_millis(100)).await;
642 assert!(result.is_err(), "should timeout, no failure expected for abort");
643
644 assert!(handle.is_finished(), "task should be finished after abort");
645 }
646
647 #[async_test]
648 async fn test_abort_on_drop_does_not_report_failure() {
649 let monitor = TaskMonitor::new();
650 let mut failures = monitor.subscribe();
651
652 // Spawn a long-running task.
653 let handle = monitor
654 .spawn_infinite_task("aborted_task", async {
655 loop {
656 sleep(Duration::from_secs(10)).await;
657 }
658 })
659 .abort_on_drop();
660
661 // Give the task time to start.
662 sleep(Duration::from_millis(10)).await;
663
664 // Abort it.
665 drop(handle);
666
667 // Should NOT receive a failure for intentional abort.
668 let result = timeout(failures.recv(), Duration::from_millis(100)).await;
669 assert!(result.is_err(), "should timeout, no failure expected for abort");
670 }
671
672 #[async_test]
673 async fn test_spawn_finite_task() {
674 let monitor = TaskMonitor::new();
675 let mut failures = monitor.subscribe();
676
677 let successful_completion = Arc::new(AtomicBool::new(false));
678
679 // Spawn a one-off background job that completes successfully.
680 let successful_completion_clone = successful_completion.clone();
681 let _handle = monitor.spawn_finite_task("one-shot job", async move {
682 sleep(Duration::from_millis(10)).await;
683 successful_completion_clone.store(true, Ordering::SeqCst);
684 });
685
686 // Give the task time to finish.
687 sleep(Duration::from_millis(20)).await;
688
689 // Should NOT receive a failure for successful completion.
690 let result = timeout(failures.recv(), Duration::from_millis(100)).await;
691 assert!(result.is_err(), "should timeout, no failure expected for abort");
692
693 assert!(
694 successful_completion.load(Ordering::SeqCst),
695 "background job should have completed successfully"
696 );
697 }
698}