Skip to main content

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.
173/// In such cases, a [`BackgroundTaskFailure`] is sent through a broadcast
174/// channel 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
238    /// operation.
239    ///
240    /// # Arguments
241    ///
242    /// * `name` - A human-readable name for the task (for debugging purposes).
243    /// * `future` - The async task to run.
244    ///
245    /// # Returns
246    ///
247    /// A [`BackgroundTaskHandle`] that can be used to abort the task or check
248    /// if it has finished. This is the equivalent of tokio's `JoinHandle`.
249    pub fn spawn_infinite_task<F>(&self, name: impl Into<String>, future: F) -> BackgroundTaskHandle
250    where
251        F: Future<Output = ()> + SendOutsideWasm + 'static,
252    {
253        self.spawn_task_internal(name, future, true)
254    }
255
256    /// Spawn a background job that is expected to run once and complete
257    /// successfully in the background.
258    ///
259    /// For long-term background jobs that are expected to run forever, use
260    /// [`Self::spawn_infinite_task`] instead.
261    ///
262    /// If the task completes (by panicking), it will be reported as a
263    /// [`BackgroundTaskFailure`] report through the broadcast channel.
264    ///
265    /// Use this for one-shot background tasks that should complete under normal
266    /// operation.
267    ///
268    /// # Arguments
269    ///
270    /// * `name` - A human-readable name for the task (for debugging purposes).
271    /// * `future` - The async task to run.
272    ///
273    /// # Returns
274    ///
275    /// A [`BackgroundTaskHandle`] that can be used to abort the task or check
276    /// if it has finished. This is the equivalent of tokio's `JoinHandle`.
277    pub fn spawn_finite_task<F>(&self, name: impl Into<String>, future: F) -> BackgroundTaskHandle
278    where
279        F: Future<Output = ()> + SendOutsideWasm + 'static,
280    {
281        self.spawn_task_internal(name, future, false)
282    }
283
284    fn spawn_task_internal<F>(
285        &self,
286        name: impl Into<String>,
287        future: F,
288        runs_forever: bool,
289    ) -> BackgroundTaskHandle
290    where
291        F: Future<Output = ()> + SendOutsideWasm + 'static,
292    {
293        let name = name.into();
294        let task_id = TaskId::new();
295        let task_info = BackgroundTaskInfo { id: task_id, name };
296
297        let intentionally_aborted = Arc::new(AtomicBool::new(false));
298
299        let active_tasks = self.active_task_handles.clone();
300        let failure_sender = self.failure_sender.clone();
301        let aborted_flag = intentionally_aborted.clone();
302
303        let wrapped = async move {
304            // SAFETY: see module-level documentation about unwind safety.
305            let result = AssertUnwindSafe(future).catch_unwind().await;
306
307            // Remove the task from the list of active ones.
308            active_tasks.write().remove(&task_id);
309
310            // Don't report if intentionally aborted.
311            if aborted_flag.load(Ordering::Acquire) {
312                return;
313            }
314
315            let failure_reason = match result {
316                Ok(()) => {
317                    if runs_forever {
318                        // The background forever task ended, this is considered an early
319                        // termination.
320                        BackgroundTaskFailureReason::EarlyTermination
321                    } else {
322                        // The task ended successfully, no failure to report.
323                        return;
324                    }
325                }
326
327                Err(panic_payload) => BackgroundTaskFailureReason::Panic {
328                    message: extract_panic_message(&panic_payload),
329                    panic_backtrace: capture_backtrace(),
330                },
331            };
332
333            let failure = BackgroundTaskFailure { task: task_info, reason: failure_reason };
334
335            // Forward failure to observers (ignore if there's none).
336            let _ = failure_sender.send(failure);
337        }
338        .instrument(Span::current());
339
340        let join_handle = spawn(wrapped);
341        let abort_handle = join_handle.abort_handle();
342
343        // Register the task.
344        self.active_task_handles
345            .write()
346            .insert(task_id, ActiveTask { _abort_handle: abort_handle.clone() });
347
348        BackgroundTaskHandle { abort_on_drop: false, abort_handle, intentionally_aborted }
349    }
350
351    /// Spawn a background task that returns a `Result`.
352    ///
353    /// The task is monitored for panics and errors; see also
354    /// [`BackgroundTaskFailure`].
355    ///
356    /// If the task returns `Ok(())`, it is considered successful and no failure
357    /// is reported.
358    ///
359    /// # Arguments
360    ///
361    /// * `name` - A human-readable name for the task (for debugging purposes).
362    /// * `future` - The async task to run.
363    ///
364    /// # Returns
365    ///
366    /// A [`BackgroundTaskHandle`] that can be used to abort the task or check
367    /// if it has finished. This is the equivalent of tokio's `JoinHandle`.
368    pub fn spawn_fallible_task<F, E>(
369        &self,
370        name: impl Into<String>,
371        future: F,
372    ) -> BackgroundTaskHandle
373    where
374        F: Future<Output = Result<(), E>> + SendOutsideWasm + 'static,
375        E: std::error::Error + SendOutsideWasm + 'static,
376    {
377        let name = name.into();
378        let task_id = TaskId::new();
379        let task_info = BackgroundTaskInfo { id: task_id, name };
380
381        let intentionally_aborted = Arc::new(AtomicBool::new(false));
382
383        let active_tasks = self.active_task_handles.clone();
384        let failure_sender = self.failure_sender.clone();
385        let aborted_flag = intentionally_aborted.clone();
386
387        let wrapped = async move {
388            let result = AssertUnwindSafe(future).catch_unwind().await;
389
390            active_tasks.write().remove(&task_id);
391
392            // Don't report if intentionally aborted.
393            if aborted_flag.load(Ordering::Acquire) {
394                return;
395            }
396
397            let failure_reason = match result {
398                Ok(Ok(())) => {
399                    // The task ended successfully, no failure to report.
400                    return;
401                }
402
403                Ok(Err(e)) => BackgroundTaskFailureReason::Error { error: e.to_string() },
404
405                Err(panic_payload) => BackgroundTaskFailureReason::Panic {
406                    message: extract_panic_message(&panic_payload),
407                    panic_backtrace: capture_backtrace(),
408                },
409            };
410
411            // Send failure (ignore if no receivers).
412            let _ = failure_sender
413                .send(BackgroundTaskFailure { task: task_info, reason: failure_reason });
414        }
415        .instrument(Span::current());
416
417        let join_handle = spawn(wrapped);
418        let abort_handle = join_handle.abort_handle();
419
420        // Register the task.
421        self.active_task_handles
422            .write()
423            .insert(task_id, ActiveTask { _abort_handle: abort_handle.clone() });
424
425        BackgroundTaskHandle { abort_on_drop: false, abort_handle, intentionally_aborted }
426    }
427}
428
429/// A handle to a spawned background task.
430///
431/// This handle can be used to abort the task or check if it has finished.
432/// When aborted through this handle, the task will NOT be reported as a
433/// failure.
434#[derive(Debug)]
435pub struct BackgroundTaskHandle {
436    /// The underlying tokio's [`AbortHandle`].
437    abort_handle: AbortHandle,
438
439    /// Should the task be safely aborted on drop?
440    ///
441    /// This won't result in a failure report, as it's an intentional abort.
442    abort_on_drop: bool,
443
444    /// An additional flag to indicate if the task was intentionally aborted, so
445    /// we don't report it as a failure when that happens.
446    intentionally_aborted: Arc<AtomicBool>,
447}
448
449impl Drop for BackgroundTaskHandle {
450    fn drop(&mut self) {
451        if self.abort_on_drop {
452            self.abort();
453        }
454    }
455}
456
457impl BackgroundTaskHandle {
458    /// Configure the handle to abort the task when dropped.
459    ///
460    /// The task will be stopped and will NOT be reported as a failure
461    /// (this is considered intentional termination).
462    pub fn abort_on_drop(mut self) -> Self {
463        self.abort_on_drop = true;
464        self
465    }
466
467    /// Abort the task.
468    ///
469    /// The task will be stopped and will NOT be reported as a failure
470    /// (this is considered intentional termination).
471    pub fn abort(&self) {
472        // Note: ordering matters here, we set the flag before aborting otherwise
473        // there's a possible race condition where the abort() is observed
474        // before the flag is set, and the task monitor would consider this an
475        // unexpected termination.
476        self.intentionally_aborted.store(true, Ordering::Release);
477        self.abort_handle.abort();
478    }
479
480    /// Check if the task has finished.
481    ///
482    /// Returns `true` if the task completed, panicked, or was aborted on
483    /// non-wasm; on wasm, returns whether the task has been aborted only
484    /// (due to lack of better APIs).
485    pub fn is_finished(&self) -> bool {
486        #[cfg(not(target_family = "wasm"))]
487        {
488            self.abort_handle.is_finished()
489        }
490        #[cfg(target_family = "wasm")]
491        {
492            self.abort_handle.is_aborted()
493        }
494    }
495}
496
497/// Capture a backtrace at the current location.
498///
499/// Returns `None` if backtraces are not enabled or not available.
500#[cfg(not(target_family = "wasm"))]
501fn capture_backtrace() -> Option<String> {
502    use std::backtrace::{Backtrace, BacktraceStatus};
503
504    let bt = Backtrace::capture();
505    if bt.status() == BacktraceStatus::Captured { Some(bt.to_string()) } else { None }
506}
507
508/// Capture a backtrace - WASM version (backtraces not typically available).
509#[cfg(target_family = "wasm")]
510fn capture_backtrace() -> Option<String> {
511    None
512}
513
514/// Extract a message from a panic payload.
515fn extract_panic_message(payload: &Box<dyn Any + Send>) -> Option<String> {
516    if let Some(s) = payload.downcast_ref::<&str>() {
517        Some((*s).to_owned())
518    } else {
519        payload.downcast_ref::<String>().cloned()
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use std::{
526        sync::{
527            Arc,
528            atomic::{AtomicBool, Ordering},
529        },
530        time::Duration,
531    };
532
533    use assert_matches::assert_matches;
534    use matrix_sdk_test_macros::async_test;
535
536    use super::{BackgroundTaskFailureReason, TaskMonitor};
537    use crate::{sleep::sleep, timeout::timeout};
538
539    #[async_test]
540    async fn test_early_termination_is_reported() {
541        let monitor = TaskMonitor::new();
542        let mut failures = monitor.subscribe();
543
544        // Spawn a task that completes immediately.
545        let _handle = monitor.spawn_infinite_task("test_task", async {
546            // Completes immediately: this is an "early termination".
547        });
548
549        // Should receive an early termination failure.
550        let failure = timeout(failures.recv(), Duration::from_secs(1))
551            .await
552            .expect("timeout waiting for failure")
553            .expect("channel closed");
554
555        assert_eq!(failure.task.name, "test_task");
556        assert_matches!(failure.reason, BackgroundTaskFailureReason::EarlyTermination);
557    }
558
559    #[async_test]
560    #[cfg(not(target_family = "wasm"))] // Unfortunately, safe unwinding doesn't work on wasm.
561    async fn test_panic_is_captured() {
562        let monitor = TaskMonitor::new();
563        let mut failures = monitor.subscribe();
564
565        // Spawn a task that panics.
566        let _handle = monitor.spawn_infinite_task("panicking_task", async {
567            panic!("test panic message");
568        });
569
570        // Should receive a panic failure.
571        let failure = timeout(failures.recv(), Duration::from_secs(1))
572            .await
573            .expect("timeout waiting for failure")
574            .expect("channel closed");
575
576        assert_eq!(failure.task.name, "panicking_task");
577        assert_matches!(
578            failure.reason,
579            BackgroundTaskFailureReason::Panic { message, .. } => {
580                assert_eq!(message.as_deref(), Some("test panic message"));
581            }
582        );
583    }
584
585    #[async_test]
586    async fn test_error_is_captured() {
587        let monitor = TaskMonitor::new();
588        let mut failures = monitor.subscribe();
589
590        // Spawn a fallible task that returns an error.
591        let _handle = monitor.spawn_fallible_task("fallible_task", async {
592            Err::<(), _>(std::io::Error::other("test error message"))
593        });
594
595        // Should receive an error failure.
596        let failure = timeout(failures.recv(), Duration::from_secs(1))
597            .await
598            .expect("timeout waiting for failure")
599            .expect("channel closed");
600
601        assert_eq!(failure.task.name, "fallible_task");
602        assert_matches!(
603            failure.reason,
604            BackgroundTaskFailureReason::Error { error } => {
605                assert!(error.contains("test error message"));
606            }
607        );
608    }
609
610    #[async_test]
611    async fn test_successful_fallible_task_no_failure() {
612        let monitor = TaskMonitor::new();
613        let mut failures = monitor.subscribe();
614
615        // Spawn a fallible task that succeeds.
616        let _handle =
617            monitor.spawn_fallible_task("success_task", async { Ok::<(), std::io::Error>(()) });
618
619        // Should NOT receive any failure: use a short timeout.
620        let result = timeout(failures.recv(), Duration::from_millis(100)).await;
621        assert!(result.is_err(), "should timeout, no failure expected");
622    }
623
624    #[async_test]
625    async fn test_abort_does_not_report_failure() {
626        let monitor = TaskMonitor::new();
627        let mut failures = monitor.subscribe();
628
629        // Spawn a long-running task.
630        let handle = monitor.spawn_infinite_task("aborted_task", async {
631            loop {
632                sleep(Duration::from_secs(10)).await;
633            }
634        });
635
636        // Give the task time to start.
637        sleep(Duration::from_millis(10)).await;
638
639        // Abort it.
640        handle.abort();
641
642        // Should NOT receive a failure for intentional abort.
643        let result = timeout(failures.recv(), Duration::from_millis(100)).await;
644        assert!(result.is_err(), "should timeout, no failure expected for abort");
645
646        assert!(handle.is_finished(), "task should be finished after abort");
647    }
648
649    #[async_test]
650    async fn test_abort_on_drop_does_not_report_failure() {
651        let monitor = TaskMonitor::new();
652        let mut failures = monitor.subscribe();
653
654        // Spawn a long-running task.
655        let handle = monitor
656            .spawn_infinite_task("aborted_task", async {
657                loop {
658                    sleep(Duration::from_secs(10)).await;
659                }
660            })
661            .abort_on_drop();
662
663        // Give the task time to start.
664        sleep(Duration::from_millis(10)).await;
665
666        // Abort it.
667        drop(handle);
668
669        // Should NOT receive a failure for intentional abort.
670        let result = timeout(failures.recv(), Duration::from_millis(100)).await;
671        assert!(result.is_err(), "should timeout, no failure expected for abort");
672    }
673
674    #[async_test]
675    async fn test_spawn_finite_task() {
676        let monitor = TaskMonitor::new();
677        let mut failures = monitor.subscribe();
678
679        let successful_completion = Arc::new(AtomicBool::new(false));
680
681        // Spawn a one-off background job that completes successfully.
682        let successful_completion_clone = successful_completion.clone();
683        let _handle = monitor.spawn_finite_task("one-shot job", async move {
684            sleep(Duration::from_millis(10)).await;
685            successful_completion_clone.store(true, Ordering::SeqCst);
686        });
687
688        // Give the task time to finish.
689        sleep(Duration::from_millis(20)).await;
690
691        // Should NOT receive a failure for successful completion.
692        let result = timeout(failures.recv(), Duration::from_millis(100)).await;
693        assert!(result.is_err(), "should timeout, no failure expected for abort");
694
695        assert!(
696            successful_completion.load(Ordering::SeqCst),
697            "background job should have completed successfully"
698        );
699    }
700}