Skip to main content

sova_tasks/
lib.rs

1//! Background task worker + optional scheduler + HTTP dispatch for Sova.
2
3mod console;
4mod job;
5mod schedule;
6mod schedule_toml;
7mod worker;
8
9pub use console::{
10    ask, confirm, enter_cli, error, info, is_interactive, line, table, warn, ConsoleGuard,
11};
12pub use job::{parse_cron, priority, Job, Schedule};
13pub use schedule::every_slot;
14
15use bytes::Bytes;
16use job::Job as JobDef;
17use job::priority as prio;
18use sova_core::{App, Error, IntoResponse, Plugin, Request, Response};
19use sova_tasks_store::{EnqueueOpts, Task, TaskError, TaskStatus, TaskStore};
20use schedule::{ScheduledJob, TaskScheduler};
21use schedule_toml::{merge_schedules, parse_schedule_toml, schedule_label};
22use serde_json::Value;
23use std::collections::HashMap;
24use std::future::Future;
25use std::pin::Pin;
26use std::sync::Arc;
27use std::time::{Duration, SystemTime};
28use worker::TaskWorker;
29
30pub(crate) type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
31pub(crate) type Handler = Arc<dyn Fn(Task) -> BoxFuture<Result<(), String>> + Send + Sync>;
32pub(crate) type HandlerMap = Arc<HashMap<String, Handler>>;
33type Guard = Arc<dyn Fn(&Request) -> bool + Send + Sync>;
34
35/// One registered job (for CLI `tasks list` / `tasks schedule`).
36#[derive(Clone, Debug)]
37pub struct JobInfo {
38    pub name: String,
39    pub queue: String,
40    pub priority: i32,
41    pub schedule: Option<String>,
42}
43
44/// App-state registry for CLI and introspection.
45#[derive(Clone)]
46pub struct TaskRegistry {
47    pub handlers: HandlerMap,
48    pub jobs: Vec<JobInfo>,
49}
50/// HTTP-facing wrapper around [`TaskError`] that maps into [`Error::Response`].
51#[derive(Debug)]
52pub struct HttpTaskError(pub TaskError);
53
54impl From<TaskError> for HttpTaskError {
55    fn from(err: TaskError) -> Self {
56        Self(err)
57    }
58}
59
60impl IntoResponse for HttpTaskError {
61    fn into_response(self) -> Response {
62        match self.0 {
63            TaskError::NotFound => Response::text("not found").status(404),
64            TaskError::Duplicate => Response::text("duplicate").status(409),
65            TaskError::Msg(msg) => Response::text(msg).status(500),
66        }
67    }
68}
69
70impl From<HttpTaskError> for Error {
71    fn from(err: HttpTaskError) -> Self {
72        Error::Response(Box::new(err.into_response()))
73    }
74}
75
76/// One-shot (or delayed) enqueue request.
77#[derive(Debug, Clone)]
78pub struct Dispatch {
79    pub name: String,
80    pub data: Value,
81    pub queue: Option<String>,
82    pub run_at: Option<SystemTime>,
83    pub delay: Option<Duration>,
84    pub dedup_key: Option<String>,
85    pub priority: Option<i32>,
86}
87
88impl Dispatch {
89    pub fn new(name: impl Into<String>) -> Self {
90        Self {
91            name: name.into(),
92            data: Value::Object(Default::default()),
93            queue: None,
94            run_at: None,
95            delay: None,
96            dedup_key: None,
97            priority: None,
98        }
99    }
100
101    pub fn data(mut self, data: Value) -> Self {
102        self.data = data;
103        self
104    }
105
106    pub fn queue(mut self, q: impl Into<String>) -> Self {
107        self.queue = Some(q.into());
108        self
109    }
110
111    pub fn at(mut self, when: SystemTime) -> Self {
112        self.run_at = Some(when);
113        self
114    }
115
116    /// Relative delay; ignored when [`Self::at`] is also set.
117    pub fn delay(mut self, d: Duration) -> Self {
118        self.delay = Some(d);
119        self
120    }
121
122    pub fn dedup(mut self, key: impl Into<String>) -> Self {
123        self.dedup_key = Some(key.into());
124        self
125    }
126
127    pub fn priority(mut self, p: i32) -> Self {
128        self.priority = Some(p);
129        self
130    }
131}
132
133/// Registers jobs and runs a worker (and optional scheduler) as [`BackgroundService`]s.
134pub struct Tasks {
135    store: Arc<dyn TaskStore>,
136    queues: Vec<String>,
137    worker_id: String,
138    lease: Duration,
139    poll: Duration,
140    jobs: Vec<JobDef>,
141    exposed: bool,
142    http_guard: Option<Guard>,
143    max_attempts: u32,
144    retry_base: Duration,
145    scheduler_tick: Duration,
146}
147
148impl Tasks {
149    pub fn new(store: Arc<dyn TaskStore>) -> Self {
150        Self {
151            store,
152            queues: vec!["default".into()],
153            worker_id: format!("w-{}", std::process::id()),
154            lease: Duration::from_secs(30),
155            poll: Duration::from_millis(200),
156            jobs: Vec::new(),
157            exposed: false,
158            http_guard: None,
159            max_attempts: 5,
160            retry_base: Duration::from_secs(5),
161            scheduler_tick: Duration::from_secs(1),
162        }
163    }
164
165    /// Single queue (shorthand for [`Self::queues`]).
166    pub fn queue(mut self, q: impl Into<String>) -> Self {
167        self.queues = vec![q.into()];
168        self
169    }
170
171    /// Claim order = priority across queues (first is highest).
172    pub fn queues<I, S>(mut self, qs: I) -> Self
173    where
174        I: IntoIterator<Item = S>,
175        S: Into<String>,
176    {
177        self.queues = qs.into_iter().map(Into::into).collect();
178        self
179    }
180
181    pub fn lease(mut self, d: Duration) -> Self {
182        self.lease = d;
183        self
184    }
185
186    pub fn poll_interval(mut self, d: Duration) -> Self {
187        self.poll = d;
188        self
189    }
190
191    pub fn max_attempts(mut self, n: u32) -> Self {
192        self.max_attempts = n.max(1);
193        self
194    }
195
196    pub fn retry_base(mut self, d: Duration) -> Self {
197        self.retry_base = d;
198        self
199    }
200
201    pub fn scheduler_tick(mut self, d: Duration) -> Self {
202        self.scheduler_tick = d.max(Duration::from_millis(50));
203        self
204    }
205
206    pub fn job(mut self, job: JobDef) -> Self {
207        self.jobs.push(job);
208        self
209    }
210
211    pub fn exposed(mut self) -> Self {
212        self.exposed = true;
213        self
214    }
215
216    pub fn guard<F>(mut self, f: F) -> Self
217    where
218        F: Fn(&Request) -> bool + Send + Sync + 'static,
219    {
220        self.http_guard = Some(Arc::new(f));
221        self
222    }
223
224    fn default_queue(&self) -> &str {
225        self.queues.first().map(String::as_str).unwrap_or("default")
226    }
227}
228
229impl Plugin for Tasks {
230    fn id(&self) -> &'static str {
231        "tasks"
232    }
233
234    fn meta(&self) -> sova_core::PluginMeta {
235        sova_core::PluginMeta::new("Tasks")
236            .description("Job worker, priorities, and optional cron/interval scheduler")
237            .version(env!("CARGO_PKG_VERSION"))
238    }
239
240    fn install(self, app: &mut App) {
241        if self.queues.is_empty() {
242            panic!("Tasks::queues must be non-empty");
243        }
244
245        let mut job_queues = HashMap::new();
246        let mut job_priorities = HashMap::new();
247        let mut handlers = HashMap::new();
248        let mut scheduled_map: HashMap<String, ScheduledJob> = HashMap::new();
249        let default_q = self.default_queue().to_string();
250
251        for job in self.jobs {
252            if let Some(ref q) = job.queue {
253                job_queues.insert(job.name.clone(), q.clone());
254            }
255            job_priorities.insert(job.name.clone(), job.priority);
256            handlers.insert(job.name.clone(), job.handler);
257            if let Some(schedule) = job.schedule {
258                let queue = job.queue.clone().unwrap_or_else(|| default_q.clone());
259                scheduled_map.insert(
260                    job.name.clone(),
261                    ScheduledJob {
262                        name: job.name,
263                        schedule,
264                        payload: job.payload,
265                        queue,
266                        priority: job.priority,
267                    },
268                );
269            }
270        }
271
272        let toml_entries = match app.config_doc() {
273            Some(doc) => match parse_schedule_toml(doc.as_ref()) {
274                Ok(v) => v,
275                Err(e) => {
276                    let msg = e.clone();
277                    app.on_startup(move |_| {
278                        let msg = msg.clone();
279                        async move {
280                            Err(Error::Internal(format!("tasks schedule toml: {msg}")))
281                        }
282                    });
283                    Vec::new()
284                }
285            },
286            None => Vec::new(),
287        };
288
289        let scheduled = match merge_schedules(
290            scheduled_map,
291            toml_entries,
292            &handlers,
293            &default_q,
294            &job_queues,
295            &job_priorities,
296        ) {
297            Ok(v) => v,
298            Err(unknown) => {
299                let msg = format!(
300                    "tasks schedule references unknown job(s): {}",
301                    unknown.join(", ")
302                );
303                let msg2 = msg.clone();
304                app.register_audit("tasks-schedule", move |_| {
305                    let msg2 = msg2.clone();
306                    async move { Err(Error::Internal(msg2)) }
307                });
308                app.on_startup(move |_| {
309                    let msg = msg.clone();
310                    async move { Err(Error::Internal(msg)) }
311                });
312                Vec::new()
313            }
314        };
315
316        let mut job_infos: Vec<JobInfo> = handlers
317            .keys()
318            .map(|name| {
319                let queue = job_queues
320                    .get(name)
321                    .cloned()
322                    .unwrap_or_else(|| default_q.clone());
323                let priority = job_priorities.get(name).copied().unwrap_or(prio::NORMAL);
324                let schedule = scheduled
325                    .iter()
326                    .find(|s| &s.name == name)
327                    .map(|s| schedule_label(&s.schedule));
328                JobInfo {
329                    name: name.clone(),
330                    queue,
331                    priority,
332                    schedule,
333                }
334            })
335            .collect();
336        job_infos.sort_by(|a, b| a.name.cmp(&b.name));
337
338        let handlers = Arc::new(handlers);
339        let registry = TaskRegistry {
340            handlers: Arc::clone(&handlers),
341            jobs: job_infos,
342        };
343        app.state(registry.clone());
344
345        let store = self.store.clone();
346        let backend = TaskBackend {
347            store: store.clone(),
348            queues: self.queues.clone(),
349            job_queues: job_queues.clone(),
350            job_priorities: job_priorities.clone(),
351        };
352        app.state(backend.clone());
353
354        let store_check = store.clone();
355        let queue_check = self.queues[0].clone();
356        app.register_check("tasks", move |_state| {
357            let store = Arc::clone(&store_check);
358            let queue = queue_check.clone();
359            async move {
360                store
361                    .list(&queue, 1)
362                    .await
363                    .map_err(|e| sova_core::Error::Internal(format!("tasks store: {e}")))?;
364                Ok(())
365            }
366        });
367
368        register_tasks_cli(app, registry);
369
370        if self.exposed {
371            let Some(guard) = self.http_guard.clone() else {
372                panic!("Tasks::exposed() requires .guard(...)");
373            };
374            let backend = backend.clone();
375            app.post("/_tasks/enqueue", move |mut req: Request| {
376                let guard = Arc::clone(&guard);
377                let backend = backend.clone();
378                async move {
379                    if !guard(&req) {
380                        return Response::text("forbidden").status(403);
381                    }
382                    #[derive(serde::Deserialize)]
383                    struct Body {
384                        name: String,
385                        #[serde(default)]
386                        payload: Value,
387                        #[serde(default)]
388                        run_at: Option<String>,
389                        #[serde(default)]
390                        delay_secs: Option<u64>,
391                        #[serde(default)]
392                        dedup_key: Option<String>,
393                        #[serde(default)]
394                        queue: Option<String>,
395                        #[serde(default)]
396                        priority: Option<i32>,
397                    }
398                    let parsed: Body = match req.json().await {
399                        Ok(v) => v,
400                        Err(e) => return Response::text(e.to_string()).status(400),
401                    };
402                    let mut d = Dispatch::new(parsed.name).data(parsed.payload);
403                    if let Some(q) = parsed.queue {
404                        d = d.queue(q);
405                    }
406                    if let Some(s) = parsed.run_at {
407                        match chrono::DateTime::parse_from_rfc3339(&s) {
408                            Ok(dt) => {
409                                d = d.at(SystemTime::from(dt.with_timezone(&chrono::Utc)));
410                            }
411                            Err(e) => {
412                                return Response::text(format!("run_at: {e}")).status(400);
413                            }
414                        }
415                    } else if let Some(secs) = parsed.delay_secs {
416                        d = d.delay(Duration::from_secs(secs));
417                    }
418                    if let Some(k) = parsed.dedup_key {
419                        d = d.dedup(k);
420                    }
421                    if let Some(p) = parsed.priority {
422                        d = d.priority(p);
423                    }
424                    match backend.dispatch(d).await {
425                        Ok(id) => Response::json(&serde_json::json!({ "id": id })),
426                        Err(e) => Response::text(e.to_string()).status(500),
427                    }
428                }
429            });
430        }
431
432        if !scheduled.is_empty() {
433            app.service(TaskScheduler {
434                store: store.clone(),
435                jobs: scheduled,
436                tick: self.scheduler_tick,
437            });
438        }
439
440        app.service(TaskWorker {
441            store: self.store,
442            queues: self.queues,
443            worker_id: self.worker_id,
444            lease: self.lease,
445            poll: self.poll,
446            handlers,
447            max_attempts: self.max_attempts,
448            retry_base: self.retry_base,
449        });
450    }
451}
452
453fn register_tasks_cli(app: &mut App, registry: TaskRegistry) {
454    app.register_cli("tasks", move |state, args| {
455        let registry = state
456            .get::<TaskRegistry>()
457            .map(|a| (*a).clone())
458            .unwrap_or_else(|| registry.clone());
459        async move {
460            let sub = args.first().map(String::as_str).unwrap_or("list");
461            match sub {
462                "list" | "" => {
463                    let rows: Vec<Vec<String>> = registry
464                        .jobs
465                        .iter()
466                        .map(|j| {
467                            vec![
468                                j.name.clone(),
469                                j.queue.clone(),
470                                j.priority.to_string(),
471                                j.schedule.clone().unwrap_or_else(|| "-".into()),
472                            ]
473                        })
474                        .collect();
475                    // Always print for list (CLI already interactive at process level).
476                    println!(
477                        "{:<24} {:<12} {:<8} SCHEDULE",
478                        "NAME", "QUEUE", "PRIO"
479                    );
480                    for r in &rows {
481                        println!(
482                            "{:<24} {:<12} {:<8} {}",
483                            r[0], r[1], r[2], r[3]
484                        );
485                    }
486                    if rows.is_empty() {
487                        println!("(no jobs registered)");
488                    }
489                    Ok(())
490                }
491                "schedule" => {
492                    let scheduled: Vec<_> = registry
493                        .jobs
494                        .iter()
495                        .filter(|j| j.schedule.is_some())
496                        .collect();
497                    if scheduled.is_empty() {
498                        println!("(no scheduled jobs)");
499                    } else {
500                        for j in scheduled {
501                            println!(
502                                "{:<24} {}  queue={}  priority={}",
503                                j.name,
504                                j.schedule.as_deref().unwrap_or("-"),
505                                j.queue,
506                                j.priority
507                            );
508                        }
509                    }
510                    Ok(())
511                }
512                "run" => {
513                    let name = args
514                        .get(1)
515                        .cloned()
516                        .ok_or_else(|| Error::Internal("usage: tasks run NAME [--json '{…}']".into()))?;
517                    let mut data = Value::Object(Default::default());
518                    if let Some(idx) = args.iter().position(|a| a == "--json") {
519                        let raw = args.get(idx + 1).ok_or_else(|| {
520                            Error::Internal("tasks run: --json requires a value".into())
521                        })?;
522                        data = serde_json::from_str(raw).map_err(|e| {
523                            Error::Internal(format!("tasks run --json: {e}"))
524                        })?;
525                    }
526                    let Some(handler) = registry.handlers.get(&name) else {
527                        return Err(Error::Internal(format!(
528                            "unknown job `{name}` (try `tasks list`)"
529                        )));
530                    };
531                    let payload = Bytes::from(
532                        serde_json::to_vec(&serde_json::json!({
533                            "name": name,
534                            "data": data,
535                        }))
536                        .map_err(|e| Error::Internal(e.to_string()))?,
537                    );
538                    let task = Task {
539                        id: format!("cli-{}", name),
540                        queue: registry
541                            .jobs
542                            .iter()
543                            .find(|j| j.name == name)
544                            .map(|j| j.queue.clone())
545                            .unwrap_or_else(|| "default".into()),
546                        payload,
547                        run_at: SystemTime::now(),
548                        attempts: 1,
549                        lease_until: None,
550                        dedup_key: None,
551                        status: TaskStatus::Running,
552                        worker: Some("cli".into()),
553                        priority: registry
554                            .jobs
555                            .iter()
556                            .find(|j| j.name == name)
557                            .map(|j| j.priority)
558                            .unwrap_or(0),
559                    };
560                    let _guard = enter_cli();
561                    handler(task)
562                        .await
563                        .map_err(|e| Error::Internal(format!("job `{name}` failed: {e}")))?;
564                    println!("ok {name}");
565                    Ok(())
566                }
567                "help" | "-h" | "--help" => {
568                    println!("usage:");
569                    println!("  tasks list");
570                    println!("  tasks schedule");
571                    println!("  tasks run NAME [--json '{{…}}']");
572                    Ok(())
573                }
574                other => Err(Error::Internal(format!(
575                    "unknown tasks subcommand `{other}` (list|schedule|run)"
576                ))),
577            }
578        }
579    });
580}
581
582/// App state handle for dispatch from request handlers.
583#[derive(Clone)]
584pub struct TaskBackend {
585    pub store: Arc<dyn TaskStore>,
586    pub queues: Vec<String>,
587    pub job_queues: HashMap<String, String>,
588    pub job_priorities: HashMap<String, i32>,
589}
590
591impl TaskBackend {
592    pub async fn dispatch(&self, d: Dispatch) -> Result<String, TaskError> {
593        let started = std::time::Instant::now();
594        let name = d.name.clone();
595        let queue = d
596            .queue
597            .clone()
598            .or_else(|| self.job_queues.get(&d.name).cloned())
599            .unwrap_or_else(|| {
600                self.queues
601                    .first()
602                    .cloned()
603                    .unwrap_or_else(|| "default".into())
604            });
605        let priority = d
606            .priority
607            .or_else(|| self.job_priorities.get(&d.name).copied())
608            .unwrap_or(prio::NORMAL);
609        let run_at = match (d.run_at, d.delay) {
610            (Some(at), _) => Some(at),
611            (None, Some(delay)) => Some(SystemTime::now() + delay),
612            (None, None) => None,
613        };
614        let payload = Bytes::from(
615            serde_json::to_vec(&serde_json::json!({
616                "name": d.name,
617                "data": d.data,
618            }))
619            .map_err(|e| TaskError::Msg(e.to_string()))?,
620        );
621        let queue_for_log = queue.clone();
622        let id = self
623            .store
624            .enqueue(EnqueueOpts {
625                queue,
626                payload,
627                run_at,
628                dedup_key: d.dedup_key,
629                priority,
630            })
631            .await?;
632        tracing::debug!(
633            target: "sova.tasks",
634            name = %name,
635            queue = %queue_for_log,
636            id = %id,
637            status = "enqueued",
638            duration_ms = started.elapsed().as_secs_f64() * 1000.0,
639            request_id = sova_core::current_request_id().as_deref().unwrap_or(""),
640            "sova.tasks enqueue"
641        );
642        Ok(id)
643    }
644}
645
646/// Require `Authorization: Bearer <token>` for task HTTP routes.
647pub fn bearer_guard(token: impl Into<String>) -> impl Fn(&Request) -> bool + Send + Sync + 'static {
648    let token = token.into();
649    move |req: &Request| {
650        req.header("authorization")
651            .map(|v| v == format!("Bearer {token}"))
652            .unwrap_or(false)
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use sova_tasks_store::MemoryStore;
660
661    #[tokio::test]
662    async fn dispatch_via_backend() {
663        let store = Arc::new(MemoryStore::new());
664        let backend = TaskBackend {
665            store: store.clone(),
666            queues: vec!["default".into()],
667            job_queues: HashMap::new(),
668            job_priorities: HashMap::new(),
669        };
670        let id = backend
671            .dispatch(Dispatch::new("ping").data(serde_json::json!({})))
672            .await
673            .unwrap();
674        assert!(id.starts_with('t'));
675        assert_eq!(store.list("default", 10).await.unwrap().len(), 1);
676    }
677
678    #[tokio::test]
679    async fn dispatch_uses_job_defaults_and_delay() {
680        let store = Arc::new(MemoryStore::new());
681        let mut job_queues = HashMap::new();
682        job_queues.insert("mail".into(), "mailer".into());
683        let mut job_priorities = HashMap::new();
684        job_priorities.insert("mail".into(), prio::LOW);
685        let backend = TaskBackend {
686            store: store.clone(),
687            queues: vec!["default".into(), "mailer".into()],
688            job_queues,
689            job_priorities,
690        };
691        let id = backend
692            .dispatch(Dispatch::new("mail").delay(Duration::from_secs(60)))
693            .await
694            .unwrap();
695        let listed = store.list("mailer", 10).await.unwrap();
696        assert_eq!(listed.len(), 1);
697        assert_eq!(listed[0].id, id);
698        assert_eq!(listed[0].priority, prio::LOW);
699        assert!(listed[0].run_at > SystemTime::now());
700    }
701
702    #[test]
703    #[should_panic(expected = "Tasks::exposed() requires .guard")]
704    fn exposed_without_guard_panics_on_install() {
705        let store = Arc::new(MemoryStore::new());
706        let mut app = App::new();
707        Tasks::new(store).exposed().install(&mut app);
708    }
709
710    #[tokio::test]
711    async fn http_enqueue_then_worker_runs_handler() {
712        use http::Method;
713        use sova_core::BackgroundService;
714        use sova_tasks_store::TaskStatus;
715        use std::sync::atomic::{AtomicUsize, Ordering};
716
717        let store = Arc::new(MemoryStore::new());
718        let ran = Arc::new(AtomicUsize::new(0));
719        let ran2 = Arc::clone(&ran);
720
721        let mut app = App::new();
722        Tasks::new(store.clone())
723            .exposed()
724            .guard(bearer_guard("secret"))
725            .job(Job::new("job", move |_| {
726                let ran2 = Arc::clone(&ran2);
727                async move {
728                    ran2.fetch_add(1, Ordering::SeqCst);
729                    Ok(())
730                }
731            }))
732            .install(&mut app);
733
734        let body = r#"{"name":"job","payload":{}}"#;
735        let res = app
736            .handle(
737                Request::builder()
738                    .method(Method::POST)
739                    .path("/_tasks/enqueue")
740                    .header("authorization", "Bearer secret")
741                    .body(body)
742                    .build(),
743            )
744            .await;
745        assert_eq!(res.status_code().as_u16(), 200);
746
747        let (tx, shutdown) = sova_core::shutdown_channel();
748        let mut handlers = HashMap::new();
749        let ran3 = Arc::clone(&ran);
750        handlers.insert(
751            "job".into(),
752            Arc::new(move |_t| {
753                let ran3 = Arc::clone(&ran3);
754                Box::pin(async move {
755                    ran3.fetch_add(1, Ordering::SeqCst);
756                    Ok(())
757                }) as crate::BoxFuture<Result<(), String>>
758            }) as Handler,
759        );
760        let worker = Box::new(TaskWorker {
761            store: store.clone(),
762            queues: vec!["default".into()],
763            worker_id: "test".into(),
764            lease: Duration::from_secs(2),
765            poll: Duration::from_millis(10),
766            handlers: Arc::new(handlers),
767            max_attempts: 5,
768            retry_base: Duration::from_millis(50),
769        });
770        let handle = tokio::spawn(worker.run(
771            Arc::new(sova_core::extend::StateMap::new()),
772            shutdown,
773        ));
774
775        let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
776        while tokio::time::Instant::now() < deadline {
777            if ran.load(Ordering::SeqCst) >= 1 {
778                break;
779            }
780            tokio::time::sleep(Duration::from_millis(20)).await;
781        }
782        assert_eq!(ran.load(Ordering::SeqCst), 1);
783        let listed = store.list("default", 10).await.unwrap();
784        assert_eq!(listed[0].status, TaskStatus::Done);
785
786        let _ = tx.send(true);
787        let _ = handle.await;
788    }
789}