Skip to main content

rocketmq_runtime/
task_group.rs

1// Copyright 2023 The RocketMQ Rust Authors
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
15use std::future::Future;
16use std::sync::atomic::AtomicU64;
17use std::sync::atomic::AtomicU8;
18use std::sync::atomic::AtomicUsize;
19use std::sync::atomic::Ordering;
20use std::sync::Arc;
21use std::time::Duration;
22use std::time::Instant;
23
24use dashmap::mapref::entry::Entry;
25use futures::future::join_all;
26use futures::future::BoxFuture;
27use futures::future::FutureExt;
28use parking_lot::Mutex;
29use serde::Serialize;
30use tokio::sync::Notify;
31use tokio::task::AbortHandle;
32use tokio_util::sync::CancellationToken;
33use tokio_util::task::TaskTracker;
34
35use crate::blocking::BlockingTaskSnapshot;
36use crate::blocking::BlockingTaskTable;
37use crate::blocking::IsolatedBlockingTables;
38use crate::critical::CriticalFailureState;
39use crate::critical::CriticalRegistration;
40use crate::error::RuntimeError;
41use crate::error::RuntimeResult;
42use crate::handle::RuntimeHandle;
43use crate::operation::OperationContext;
44use crate::service_context::RootGroupPermit;
45use crate::shutdown_deadline::ShutdownDeadline;
46use crate::shutdown_report::ShutdownAnnotation;
47use crate::shutdown_report::ShutdownReport;
48use crate::shutdown_report::TaskSnapshot;
49
50mod completion;
51mod diagnostics;
52mod registry;
53mod shutdown;
54mod submission;
55
56use registry::ActiveTaskRegistry;
57use shutdown::ShutdownCoordinator;
58
59const STATE_OPEN: u8 = 0;
60const STATE_CLOSING: u8 = 1;
61const STATE_CLOSED: u8 = 2;
62const STATE_SHUTDOWN_COMPLETED: u8 = 3;
63const STATE_POISONED: u8 = 4;
64
65// Apply the large-future boundary before adding lifecycle and tracker wrappers. Waiting for
66// Tokio's spawn boundary leaves their by-value stack frames live during task submission.
67const MAX_INLINE_TASK_FUTURE_SIZE: usize = crate::stack::MAX_INLINE_SIZE;
68
69/// Identifies a task within the group that owns it.
70///
71/// The id records its owning group, so a group never mistakes another
72/// group's task for one of its own finished tasks. It serializes as the
73/// per-group sequence number; reports carry the group id separately.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
75#[serde(transparent)]
76pub struct TaskId {
77    sequence: u64,
78    #[serde(skip)]
79    group: TaskGroupId,
80}
81
82impl TaskId {
83    pub(crate) fn new(group: TaskGroupId, sequence: u64) -> Self {
84        Self { sequence, group }
85    }
86
87    /// Returns the sequence number of this task within its group.
88    pub fn as_u64(self) -> u64 {
89        self.sequence
90    }
91
92    /// Returns the id of the group that owns this task.
93    pub fn group_id(self) -> TaskGroupId {
94        self.group
95    }
96}
97
98/// Identifies a task group.
99///
100/// Ids are unique within the process: groups of different runtime owners or
101/// contexts never share an id.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
103pub struct TaskGroupId(u64);
104
105impl TaskGroupId {
106    /// Borrows this value as u64.
107    pub fn as_u64(self) -> u64 {
108        self.0
109    }
110
111    fn next() -> Self {
112        static NEXT_TASK_GROUP_ID: AtomicU64 = AtomicU64::new(1);
113        Self(NEXT_TASK_GROUP_ID.fetch_add(1, Ordering::Relaxed))
114    }
115}
116
117/// Name of a task, shown in shutdown reports and diagnostics.
118///
119/// A `&'static str` is stored without allocating; owned and shared strings
120/// are kept as they are.
121#[derive(Debug, Clone, PartialEq, Eq, Hash)]
122pub struct TaskName(TaskNameRepr);
123
124#[derive(Debug, Clone, PartialEq, Eq, Hash)]
125enum TaskNameRepr {
126    Static(&'static str),
127    Shared(Arc<str>),
128}
129
130impl TaskName {
131    /// Returns the name.
132    pub fn as_str(&self) -> &str {
133        match &self.0 {
134            TaskNameRepr::Static(name) => name,
135            TaskNameRepr::Shared(name) => name,
136        }
137    }
138}
139
140impl std::fmt::Display for TaskName {
141    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        formatter.write_str(self.as_str())
143    }
144}
145
146impl From<&'static str> for TaskName {
147    fn from(name: &'static str) -> Self {
148        Self(TaskNameRepr::Static(name))
149    }
150}
151
152impl From<String> for TaskName {
153    fn from(name: String) -> Self {
154        Self(TaskNameRepr::Shared(Arc::from(name)))
155    }
156}
157
158impl From<&String> for TaskName {
159    fn from(name: &String) -> Self {
160        Self(TaskNameRepr::Shared(Arc::from(name.as_str())))
161    }
162}
163
164impl From<Arc<str>> for TaskName {
165    fn from(name: Arc<str>) -> Self {
166        Self(TaskNameRepr::Shared(name))
167    }
168}
169
170impl From<&Arc<str>> for TaskName {
171    fn from(name: &Arc<str>) -> Self {
172        Self(TaskNameRepr::Shared(Arc::clone(name)))
173    }
174}
175
176impl From<Box<str>> for TaskName {
177    fn from(name: Box<str>) -> Self {
178        Self(TaskNameRepr::Shared(Arc::from(name)))
179    }
180}
181
182impl From<std::borrow::Cow<'static, str>> for TaskName {
183    fn from(name: std::borrow::Cow<'static, str>) -> Self {
184        match name {
185            std::borrow::Cow::Borrowed(name) => Self::from(name),
186            std::borrow::Cow::Owned(name) => Self::from(name),
187        }
188    }
189}
190
191/// Counts of failures that a task group tree absorbs without an error.
192#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
193pub struct TaskGroupEventCounts {
194    /// Groups poisoned by a panicking task.
195    pub poisoned_groups: u64,
196    /// Component requests answered with an already closed group, because the
197    /// owner no longer admitted children.
198    pub closed_component_requests: u64,
199}
200
201/// State shared by every group of one tree: absorbed-failure counters and the
202/// isolated blocking executors whose work the tree owner reports.
203#[derive(Debug, Default)]
204struct TaskGroupTreeState {
205    poisoned_groups: AtomicU64,
206    closed_component_requests: AtomicU64,
207    isolated_blocking: IsolatedBlockingTables,
208}
209
210/// Identifies the task kind state.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
212pub enum TaskKind {
213    /// Represents the service case.
214    Service,
215    /// Represents the worker case.
216    Worker,
217    /// Represents the scheduled driver case.
218    ScheduledDriver,
219    /// Represents the scheduled run case.
220    ScheduledRun,
221    /// Represents the blocking reaper case.
222    BlockingReaper,
223    /// Represents the shutdown case.
224    Shutdown,
225    /// Represents the other case.
226    Other,
227}
228
229impl TaskKind {
230    pub(crate) const ALL: [Self; 7] = [
231        Self::Service,
232        Self::Worker,
233        Self::ScheduledDriver,
234        Self::ScheduledRun,
235        Self::BlockingReaper,
236        Self::Shutdown,
237        Self::Other,
238    ];
239    pub(crate) const COUNT: usize = Self::ALL.len();
240
241    pub(crate) const fn index(self) -> usize {
242        match self {
243            Self::Service => 0,
244            Self::Worker => 1,
245            Self::ScheduledDriver => 2,
246            Self::ScheduledRun => 3,
247            Self::BlockingReaper => 4,
248            Self::Shutdown => 5,
249            Self::Other => 6,
250        }
251    }
252}
253
254/// Identifies the task state.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
256pub enum TaskState {
257    /// Represents the queued case.
258    Queued,
259    /// Represents the running case.
260    Running,
261    /// Represents the completed case.
262    Completed,
263    /// Represents the cancelled case.
264    Cancelled,
265    /// Represents the aborted case.
266    Aborted,
267    /// Represents the panicked case.
268    Panicked,
269    /// Represents the leaked case.
270    Leaked,
271}
272
273/// Identifies the task result state.
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
275pub enum TaskResult {
276    /// Represents the completed case.
277    Completed,
278    /// Represents the cancelled case.
279    Cancelled,
280    /// Represents the aborted case.
281    Aborted,
282    /// Represents the panicked case.
283    Panicked,
284}
285
286/// Lifecycle state of a task group.
287///
288/// A shutdown moves a group through `Closing`, `Closed` and
289/// `ShutdownCompleted` in that order.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
291pub enum TaskGroupLifecycleState {
292    /// Accepts tasks and child groups.
293    Open,
294    /// Admission is closed; cancellation has not yet reached every task.
295    Closing,
296    /// Admission is closed and cancellation was broadcast; owned work is draining.
297    Closed,
298    /// Shutdown finished and its report is published.
299    ShutdownCompleted,
300    /// A task panicked; the group rejects new work until it is shut down.
301    Poisoned,
302}
303
304impl TaskGroupLifecycleState {
305    /// Returns the failure reported to a submission rejected in this state.
306    ///
307    /// Callers use it only after observing a state other than `Open`.
308    pub(crate) fn admission_error(self, operation: crate::RuntimeOperation) -> RuntimeError {
309        match self {
310            Self::Poisoned => RuntimeError::poisoned(operation),
311            Self::Open | Self::Closing | Self::Closed | Self::ShutdownCompleted => RuntimeError::closed(operation),
312        }
313    }
314}
315
316/// Represents a task-group owner.
317///
318/// Cloning a task group keeps the same owner and cancellation token. Use
319/// [`Self::try_child`] when work needs an independently cancellable owner whose
320/// lifetime remains bounded by this parent.
321///
322/// Dropping a handle does not request cancellation or wait for cleanup.
323/// Active descendants retain their ancestor ownership path. Call
324/// [`Self::shutdown_until`] to close admission, cancel and await the subtree.
325/// A task panic poisons its group against new submissions; it remains owned
326/// and can still be shut down and reported.
327#[derive(Debug, Clone)]
328pub struct TaskGroup {
329    inner: Arc<TaskGroupInner>,
330}
331
332#[derive(Debug, Clone, Copy)]
333pub(crate) struct TaskKindDiagnostics {
334    pub(crate) kind: TaskKind,
335    pub(crate) active: usize,
336    pub(crate) long_running: usize,
337    pub(crate) max_elapsed: Duration,
338}
339
340#[derive(Debug, Clone)]
341pub(crate) struct TaskGroupDiagnostics {
342    pub(crate) local_task_count: usize,
343    pub(crate) group_count: usize,
344    pub(crate) task_count: usize,
345    pub(crate) task_kinds: Vec<TaskKindDiagnostics>,
346}
347
348/// Which population one bounded task detail was observed in.
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350pub(crate) enum TaskDetailScope {
351    /// A task of the group the scan started from.
352    Local,
353    /// A task of one of its descendant groups.
354    Subtree,
355}
356
357/// One bounded task detail.
358///
359/// Deliberately carries no task identifier, task name, or group name: a detail
360/// list is served through an authenticated endpoint, and those values are
361/// caller-provided labels that diagnostics must not disclose.
362#[derive(Debug, Clone)]
363pub(crate) struct TaskDetail {
364    pub(crate) kind: TaskKind,
365    pub(crate) scope: TaskDetailScope,
366    pub(crate) elapsed: Duration,
367}
368
369/// The result of one bounded detail scan.
370#[derive(Debug, Clone, Default)]
371pub(crate) struct TaskDetailScan {
372    /// How many tasks were examined before the scan budget was reached.
373    pub(crate) scanned: usize,
374    /// Descendant registrations examined, including expired weak entries.
375    pub(crate) group_entries_scanned: usize,
376    /// The details that fit inside the output budget.
377    pub(crate) details: Vec<TaskDetail>,
378    /// Whether either budget stopped the scan short of every task.
379    pub(crate) truncated: bool,
380}
381
382#[derive(Debug)]
383struct TaskGroupInner {
384    id: TaskGroupId,
385    parent_id: Option<TaskGroupId>,
386    name: Arc<str>,
387    runtime: RuntimeHandle,
388    cancellation_token: CancellationToken,
389    tracker: TaskTracker,
390    registry: Arc<ActiveTaskRegistry>,
391    // A live descendant retains the entire ownership path. The parent's
392    // registry points back weakly, so dropping an idle subtree releases it.
393    parent: Option<Arc<TaskGroupInner>>,
394    tree: Arc<TaskGroupTreeState>,
395    // Component requests this group answered with a closed group.
396    closed_component_requests: AtomicUsize,
397    next_task_id: AtomicU64,
398    completed: AtomicUsize,
399    cancelled: AtomicUsize,
400    aborted: AtomicUsize,
401    panicked: AtomicUsize,
402    lifecycle: AtomicU8,
403    spawn_gate: Mutex<()>,
404    settlement_gate: Mutex<()>,
405    shutdown: ShutdownCoordinator,
406}
407
408#[derive(Debug, Clone)]
409struct TaskMeta {
410    id: TaskId,
411    name: TaskName,
412    group_id: TaskGroupId,
413    group_name: Arc<str>,
414    kind: TaskKind,
415    state: TaskState,
416    started_at: Instant,
417    abort_handle: Option<AbortHandle>,
418    abort_requested: bool,
419    completion: Arc<TaskCompletion>,
420    /// Id of the operation that submitted the task, if any.
421    operation: Option<u64>,
422}
423
424#[derive(Debug)]
425struct TaskCompletion {
426    done: AtomicU8,
427    notify: Notify,
428}
429
430impl TaskCompletion {
431    fn new() -> Self {
432        Self {
433            done: AtomicU8::new(0),
434            notify: Notify::new(),
435        }
436    }
437
438    fn mark_done(&self) {
439        if self.done.swap(1, Ordering::AcqRel) == 0 {
440            self.notify.notify_waiters();
441        }
442    }
443
444    fn is_done(&self) -> bool {
445        self.done.load(Ordering::Acquire) != 0
446    }
447
448    async fn wait(&self) {
449        loop {
450            let notified = self.notify.notified();
451            if self.is_done() {
452                return;
453            }
454            notified.await;
455        }
456    }
457}
458
459impl TaskGroup {
460    /// Creates the root group of a runtime scope tree.
461    ///
462    /// Only [`RootServiceContext::new`](crate::RootServiceContext) can create
463    /// the permit, so every other group is a descendant of a root context.
464    pub(crate) fn root(name: Arc<str>, runtime: RuntimeHandle, _permit: RootGroupPermit) -> Self {
465        Self {
466            inner: Arc::new(TaskGroupInner::new(
467                TaskGroupId::next(),
468                None,
469                name,
470                runtime,
471                CancellationToken::new(),
472                None,
473                Arc::default(),
474            )),
475        }
476    }
477
478    /// Returns the id.
479    pub fn id(&self) -> TaskGroupId {
480        self.inner.id
481    }
482
483    pub(crate) fn runtime(&self) -> &RuntimeHandle {
484        &self.inner.runtime
485    }
486
487    /// Returns the parent id.
488    pub fn parent_id(&self) -> Option<TaskGroupId> {
489        self.inner.parent_id
490    }
491
492    /// Returns the name.
493    pub fn name(&self) -> &str {
494        &self.inner.name
495    }
496
497    /// Returns the cancellation token.
498    pub fn cancellation_token(&self) -> CancellationToken {
499        self.inner.cancellation_token.clone()
500    }
501
502    /// Returns the earliest absolute deadline installed by a shutdown owner.
503    pub fn shutdown_deadline(&self) -> Option<ShutdownDeadline> {
504        self.inner.shutdown.deadline()
505    }
506
507    /// Returns the lifecycle state.
508    pub fn lifecycle_state(&self) -> TaskGroupLifecycleState {
509        self.inner.lifecycle_state()
510    }
511
512    /// Returns the active count registered directly in this group.
513    ///
514    /// Descendants are excluded; subtree diagnostics aggregate them separately.
515    pub fn task_count(&self) -> usize {
516        self.inner.registry.tasks.len()
517    }
518
519    /// Returns the number of active component groups directly owned by this group.
520    pub fn component_count(&self) -> usize {
521        self.inner.registry.component_count()
522    }
523
524    /// Returns whether `task_id` was issued by this group, whether or not the
525    /// task is still registered.
526    pub fn owns_task(&self, task_id: TaskId) -> bool {
527        task_id.group_id() == self.inner.id
528    }
529
530    /// Returns whether `task_id` is a registered task of this group.
531    ///
532    /// Returns `false` for an id issued by another group.
533    pub fn contains_task(&self, task_id: TaskId) -> bool {
534        self.inner.registry.tasks.contains_key(&task_id)
535    }
536
537    /// Holds the settlement gate, so a finished task stays registered until
538    /// the guard is dropped.
539    #[cfg(test)]
540    pub(crate) fn lock_settlement_for_test(&self) -> parking_lot::MutexGuard<'_, ()> {
541        self.inner.settlement_gate.lock()
542    }
543
544    /// Returns the registered tasks of the operation with id `operation`.
545    pub(crate) fn operation_task_ids(&self, operation: u64) -> Vec<TaskId> {
546        self.inner
547            .registry
548            .tasks
549            .iter()
550            .filter(|entry| entry.operation == Some(operation))
551            .map(|entry| entry.id)
552            .collect()
553    }
554
555    /// Returns the counts of absorbed failures over this group's whole tree.
556    pub fn event_counts(&self) -> TaskGroupEventCounts {
557        let tree = &self.inner.tree;
558        TaskGroupEventCounts {
559            poisoned_groups: tree.poisoned_groups.load(Ordering::Relaxed),
560            closed_component_requests: tree.closed_component_requests.load(Ordering::Relaxed),
561        }
562    }
563
564    /// Keeps an isolated executor's task table visible to the owner of this
565    /// group's tree.
566    pub(crate) fn register_isolated_blocking(&self, table: &Arc<BlockingTaskTable>) {
567        self.inner.tree.isolated_blocking.register(table);
568    }
569
570    /// Returns the still-running count and task snapshots of the isolated
571    /// executors bound to this group's tree.
572    pub(crate) fn isolated_blocking_report(&self) -> (usize, Vec<BlockingTaskSnapshot>) {
573        self.inner.tree.isolated_blocking.report()
574    }
575
576    /// Creates a component, or a closed group once this owner stops admitting children.
577    ///
578    /// Callers keep a usable handle either way; the first closed answer per
579    /// owner is logged and every one is counted in [`Self::event_counts`].
580    pub(crate) fn component(&self, name: impl Into<Arc<str>>) -> Self {
581        let name = name.into();
582        self.try_child(name.clone()).unwrap_or_else(|_error| {
583            self.inner
584                .tree
585                .closed_component_requests
586                .fetch_add(1, Ordering::Relaxed);
587            if self.inner.closed_component_requests.fetch_add(1, Ordering::Relaxed) == 0 {
588                tracing::warn!(
589                    group = %self.inner.path(),
590                    component = %name,
591                    state = ?self.lifecycle_state(),
592                    "component requested after its owner stopped admitting children; returning a closed group"
593                );
594            }
595            self.closed_component(name)
596        })
597    }
598
599    /// Creates an independently cancellable child owned by this task group.
600    ///
601    /// Parent cancellation propagates to the returned child. Cancelling the
602    /// child does not cancel this parent or any sibling child. In contrast,
603    /// [`Clone::clone`] keeps the same owner and cancellation token.
604    ///
605    /// # Errors
606    ///
607    /// Returns a [`crate::RuntimeErrorKind::Closed`] failure after this owner
608    /// starts shutting down, or [`crate::RuntimeErrorKind::Poisoned`] after a
609    /// task panic poisoned it.
610    pub fn try_child(&self, name: impl Into<Arc<str>>) -> RuntimeResult<Self> {
611        let name = name.into();
612        let _spawn_guard = self.inner.spawn_gate.lock();
613        let state = self.inner.lifecycle_state();
614        if state != TaskGroupLifecycleState::Open {
615            return Err(state.admission_error(crate::RuntimeOperation::CreateTaskGroupChild));
616        }
617
618        let child = self.open_component(name);
619        self.inner
620            .registry
621            .register_component(child.id(), Arc::downgrade(&child.inner));
622        Ok(child)
623    }
624
625    fn open_component(&self, name: Arc<str>) -> Self {
626        Self {
627            inner: Arc::new(TaskGroupInner::new(
628                TaskGroupId::next(),
629                Some(self.inner.id),
630                name,
631                self.inner.runtime.clone(),
632                self.inner.cancellation_token.child_token(),
633                Some(self.inner.clone()),
634                Arc::clone(&self.inner.tree),
635            )),
636        }
637    }
638
639    fn closed_component(&self, name: Arc<str>) -> Self {
640        let child = self.open_component(name);
641        child.inner.tracker.close();
642        child.inner.cancellation_token.cancel();
643        child.inner.lifecycle.store(STATE_SHUTDOWN_COMPLETED, Ordering::Release);
644        child
645    }
646
647    /// Spawns the supplied task.
648    pub fn spawn<F>(&self, name: impl Into<TaskName>, kind: TaskKind, future: F) -> RuntimeResult<TaskId>
649    where
650        F: Future<Output = ()> + Send + 'static,
651    {
652        self.spawn_inner(name.into(), kind, future)
653    }
654
655    /// Spawns a task whose panic is recorded as a critical failure.
656    ///
657    /// Registration is explicit and opt-in: a task spawned the ordinary way keeps
658    /// today's behavior. A normal completion is not a failure here, because a
659    /// worker or job may legitimately return; use [`Self::spawn_critical_service`]
660    /// when returning before owner cancellation must be treated as one.
661    ///
662    /// # Errors
663    ///
664    /// Returns an error when this task group is shutting down or closed.
665    pub fn spawn_critical<F>(
666        &self,
667        name: impl Into<TaskName>,
668        kind: TaskKind,
669        failures: CriticalFailureState,
670        future: F,
671    ) -> RuntimeResult<TaskId>
672    where
673        F: Future<Output = ()> + Send + 'static,
674    {
675        let registration = CriticalRegistration {
676            failures,
677            task_kind: kind,
678            expects_until_cancelled: false,
679        };
680        let (task_id, join_handle) =
681            self.spawn_inner_with_handle(name.into(), kind, false, Some(registration), None, future)?;
682        drop(join_handle);
683        Ok(task_id)
684    }
685
686    /// Spawns a critical service that must run until its owner is cancelled.
687    ///
688    /// Both a panic and a return before owner cancellation are recorded as
689    /// critical failures. Owner cancellation is an expected exit and records
690    /// nothing, so an ordinary shutdown never triggers failure handling.
691    ///
692    /// # Errors
693    ///
694    /// Returns an error when this task group is shutting down or closed.
695    pub fn spawn_critical_service<F>(
696        &self,
697        name: impl Into<TaskName>,
698        failures: CriticalFailureState,
699        future: F,
700    ) -> RuntimeResult<TaskId>
701    where
702        F: Future<Output = ()> + Send + 'static,
703    {
704        let registration = CriticalRegistration {
705            failures,
706            task_kind: TaskKind::Service,
707            expects_until_cancelled: true,
708        };
709        let (task_id, join_handle) =
710            self.spawn_inner_with_handle(name.into(), TaskKind::Service, false, Some(registration), None, future)?;
711        drop(join_handle);
712        Ok(task_id)
713    }
714
715    /// Spawns a service that owns its shutdown protocol.
716    ///
717    /// The service remains tracked during owner shutdown, but its future must
718    /// observe an appropriate cancellation signal and perform any required
719    /// ordered cleanup itself. Use [`Self::spawn_cancellable_service`] when it
720    /// is safe to drop the service future as soon as its owner is cancelled.
721    pub fn spawn_service<F>(&self, name: impl Into<TaskName>, future: F) -> RuntimeResult<TaskId>
722    where
723        F: Future<Output = ()> + Send + 'static,
724    {
725        self.spawn(name, TaskKind::Service, future)
726    }
727
728    /// Spawns a service that exits when either its future completes or its owner is cancelled.
729    ///
730    /// Owner cancellation drops `future`; services that require an ordered
731    /// cleanup sequence must use [`Self::spawn_service`] and observe a
732    /// cancellation token explicitly.
733    ///
734    /// # Errors
735    ///
736    /// Returns an error when this task group is shutting down or closed.
737    pub fn spawn_cancellable_service<F>(&self, name: impl Into<TaskName>, future: F) -> RuntimeResult<TaskId>
738    where
739        F: Future<Output = ()> + Send + 'static,
740    {
741        if std::mem::size_of::<F>() > MAX_INLINE_TASK_FUTURE_SIZE {
742            self.spawn_cancellable_service_inner(name.into(), Box::pin(future))
743        } else {
744            self.spawn_cancellable_service_inner(name.into(), future)
745        }
746    }
747
748    fn spawn_cancellable_service_inner<F>(&self, name: TaskName, future: F) -> RuntimeResult<TaskId>
749    where
750        F: Future<Output = ()> + Send + 'static,
751    {
752        let owner_cancellation = self.cancellation_token();
753        self.spawn_service(name, async move {
754            tokio::select! {
755                biased;
756                _ = owner_cancellation.cancelled() => {}
757                _ = future => {}
758            }
759        })
760    }
761
762    /// Spawns work for a bounded operation under this fixed component owner.
763    ///
764    /// The operation context supplies task classification, cancellation, and
765    /// an optional deadline without creating a child task group.
766    pub fn spawn_operation<F>(
767        &self,
768        context: &OperationContext,
769        name: impl Into<TaskName>,
770        future: F,
771    ) -> RuntimeResult<TaskId>
772    where
773        F: Future<Output = ()> + Send + 'static,
774    {
775        self.spawn_operation_with_cancellation(context, name.into(), future, Some(self.cancellation_token()))
776    }
777
778    /// Spawns accepted operation work that must drain during owner shutdown.
779    ///
780    /// Unlike [`Self::spawn_operation`], owner cancellation does not drop the
781    /// supplied future. The operation context still enforces its own
782    /// cancellation and deadline, and [`OperationContext::wait`] aborts work
783    /// that outlives the caller's drain deadline. This is intended for work
784    /// that has already been accepted and whose ordered shutdown protocol is
785    /// owned by a tracked service.
786    pub fn spawn_draining_operation<F>(
787        &self,
788        context: &OperationContext,
789        name: impl Into<TaskName>,
790        future: F,
791    ) -> RuntimeResult<TaskId>
792    where
793        F: Future<Output = ()> + Send + 'static,
794    {
795        self.spawn_operation_with_cancellation(context, name.into(), future, None)
796    }
797
798    fn spawn_operation_with_cancellation<F>(
799        &self,
800        context: &OperationContext,
801        name: TaskName,
802        future: F,
803        owner_cancellation: Option<CancellationToken>,
804    ) -> RuntimeResult<TaskId>
805    where
806        F: Future<Output = ()> + Send + 'static,
807    {
808        if std::mem::size_of::<F>() > MAX_INLINE_TASK_FUTURE_SIZE {
809            self.spawn_operation_inner(context, name, Box::pin(future), owner_cancellation)
810        } else {
811            self.spawn_operation_inner(context, name, future, owner_cancellation)
812        }
813    }
814
815    fn spawn_operation_inner<F>(
816        &self,
817        context: &OperationContext,
818        name: TaskName,
819        future: F,
820        owner_cancellation: Option<CancellationToken>,
821    ) -> RuntimeResult<TaskId>
822    where
823        F: Future<Output = ()> + Send + 'static,
824    {
825        let registration = {
826            let _operation_spawn_guard = context.spawn_guard();
827            context.prepare_spawn(self.id())?
828        };
829        let guard = registration.guard();
830        let execution = crate::operation::OperationExecution::new(future, guard, context.clone(), owner_cancellation);
831        let task_id = self.spawn_operation_task(name, context.task_kind(), context.id(), execution.run())?;
832        registration.finish_registration();
833        Ok(task_id)
834    }
835
836    /// Spawns with handle.
837    pub fn spawn_with_handle<F>(
838        &self,
839        name: impl Into<TaskName>,
840        kind: TaskKind,
841        future: F,
842    ) -> RuntimeResult<(TaskId, tokio::task::JoinHandle<()>)>
843    where
844        F: Future<Output = ()> + Send + 'static,
845    {
846        self.spawn_inner_with_handle(name.into(), kind, true, None, None, future)
847    }
848
849    /// Spawns service with handle.
850    pub fn spawn_service_with_handle<F>(
851        &self,
852        name: impl Into<TaskName>,
853        future: F,
854    ) -> RuntimeResult<(TaskId, tokio::task::JoinHandle<()>)>
855    where
856        F: Future<Output = ()> + Send + 'static,
857    {
858        self.spawn_with_handle(name, TaskKind::Service, future)
859    }
860
861    /// Signals cancellation to this group and its descendants without waiting.
862    ///
863    /// The signal does not close submission admission or confirm task
864    /// destruction. Services must observe it and perform their own cleanup.
865    /// Use [`Self::shutdown_until`] for the complete shutdown boundary.
866    pub fn cancel(&self) {
867        self.inner.cancellation_token.cancel();
868    }
869
870    /// Requests cancellation of an active task without waiting for destruction.
871    ///
872    /// Returns whether the task was still registered. Its record and resources
873    /// remain owned until the executor destroys the future. An id issued by
874    /// another group is never registered here, so it returns `false`.
875    pub fn abort_task(&self, task_id: TaskId) -> bool {
876        self.abort_task_inner(task_id).is_some()
877    }
878
879    /// Requests cancellation and waits until the user future is destroyed.
880    ///
881    /// Returns `false` if the task was already absent, was issued by another
882    /// group, or the wait expired. A timeout does not remove the task's record
883    /// or confirm cancellation.
884    pub async fn abort_task_and_wait(&self, task_id: TaskId, timeout: Duration) -> bool {
885        self.abort_task_until(task_id, ShutdownDeadline::after(timeout)).await
886    }
887
888    pub(crate) async fn abort_task_until(&self, task_id: TaskId, deadline: ShutdownDeadline) -> bool {
889        let Some(completion) = self.abort_task_inner(task_id) else {
890            return false;
891        };
892
893        if completion.is_done() {
894            return true;
895        }
896
897        if deadline.is_expired() {
898            return false;
899        }
900
901        tokio::time::timeout_at(deadline.instant().into(), completion.wait())
902            .await
903            .is_ok()
904    }
905
906    /// Asynchronously waits for a local task's future to be destroyed.
907    ///
908    /// Returns `true` when this group's task is already absent, including after
909    /// earlier completion, and `false` when a registered task outlives the
910    /// timeout. Unlike [`Self::abort_task_and_wait`], absence is treated as
911    /// success. An id issued by another group returns `false` immediately:
912    /// this group cannot tell whether that task finished; see
913    /// [`Self::owns_task`]. This does not request cancellation or prove
914    /// business-level success.
915    pub async fn wait_task(&self, task_id: TaskId, timeout: Duration) -> bool {
916        self.wait_task_until(task_id, ShutdownDeadline::after(timeout)).await
917    }
918
919    pub(crate) async fn wait_task_until(&self, task_id: TaskId, deadline: ShutdownDeadline) -> bool {
920        if !self.owns_task(task_id) {
921            return false;
922        }
923        let Some(completion) = self
924            .inner
925            .registry
926            .tasks
927            .get(&task_id)
928            .map(|meta| meta.completion.clone())
929        else {
930            return true;
931        };
932
933        if completion.is_done() {
934            return true;
935        }
936
937        if deadline.is_expired() {
938            return false;
939        }
940
941        tokio::time::timeout_at(deadline.instant().into(), completion.wait())
942            .await
943            .is_ok()
944    }
945
946    /// Closes and asynchronously drains this subtree within a relative budget.
947    ///
948    /// The budget is converted once to an absolute deadline. See
949    /// [`Self::shutdown_until`] for repeated calls and completion semantics.
950    pub fn shutdown(&self, timeout: Duration) -> BoxFuture<'_, ShutdownReport> {
951        self.shutdown_until(ShutdownDeadline::after(timeout))
952    }
953
954    /// Closes admission, signals cancellation and asynchronously awaits owned work.
955    ///
956    /// All children share the absolute deadline. A later call can tighten but
957    /// cannot extend it. Tasks that do not finish in time are asked to abort;
958    /// the report distinguishes confirmed destruction from remaining work.
959    /// Once published, the report is retained and returned by subsequent calls.
960    pub fn shutdown_until(&self, deadline: ShutdownDeadline) -> BoxFuture<'_, ShutdownReport> {
961        self.tighten_shutdown_deadline(deadline);
962        async move {
963            let report = self
964                .inner
965                .shutdown
966                .report
967                .get_or_init(|| async { self.shutdown_inner().await })
968                .await;
969            self.inner.shutdown.report_ready.notify_waiters();
970            report.clone()
971        }
972        .boxed()
973    }
974
975    /// Requests immediate shutdown without waiting for future destruction.
976    ///
977    /// The retained report may contain unconfirmed remaining work even when
978    /// cancellation completes shortly afterward. A later graceful call returns
979    /// that same report; it does not turn this call into an awaited drain.
980    pub fn shutdown_now(&self) -> ShutdownReport {
981        if let Some(report) = self.inner.shutdown.report.get() {
982            return report.clone();
983        }
984
985        let report = self.shutdown_now_inner();
986        let _ = self.inner.shutdown.report.set(report.clone());
987        self.inner.shutdown.report_ready.notify_waiters();
988        report
989    }
990
991    fn tighten_shutdown_deadline(&self, deadline: ShutdownDeadline) {
992        let mut pending = vec![(self.clone(), deadline)];
993        while let Some((group, deadline)) = pending.pop() {
994            let installed = group.inner.shutdown.tighten(deadline);
995            pending.extend(
996                group
997                    .inner
998                    .registry
999                    .components_snapshot()
1000                    .into_iter()
1001                    .map(|child| (child, installed)),
1002            );
1003        }
1004    }
1005
1006    async fn shutdown_inner(&self) -> ShutdownReport {
1007        let started_at = Instant::now();
1008        let deadline = self
1009            .inner
1010            .shutdown
1011            .deadline()
1012            .unwrap_or_else(|| ShutdownDeadline::after(Duration::ZERO));
1013        // Retain the entire accepted subtree before propagating cancellation.
1014        // A fast-finishing leaf must not erase its ancestors and outcomes while
1015        // shutdown is still walking the tree.
1016        let descendants = self.close_admission_tree();
1017        // Reapply after sealing admission, including children created between
1018        // the initial deadline traversal and the admission traversal.
1019        self.tighten_shutdown_deadline(deadline);
1020        self.inner.cancellation_token.cancel();
1021        self.mark_closed(&descendants);
1022        // Poll every scope at one level. A scope observes child reports via
1023        // notifications rather than recursively polling their shutdown futures.
1024        let children = join_all(descendants.iter().map(|group| async move {
1025            group.inner.shutdown.report.get_or_init(|| group.shutdown_one()).await;
1026            group.inner.shutdown.report_ready.notify_waiters();
1027        }));
1028        let (_, mut report) = tokio::join!(children, self.shutdown_one());
1029        report.elapsed = started_at.elapsed();
1030        report
1031    }
1032
1033    async fn shutdown_one(&self) -> ShutdownReport {
1034        let started_at = Instant::now();
1035        let children = self.inner.registry.components_snapshot();
1036
1037        let child_reports = async {
1038            join_all(
1039                children
1040                    .into_iter()
1041                    .map(|child| async move { child.inner.shutdown.wait_report().await.clone() }),
1042            )
1043            .await
1044        };
1045        // On a deadline, returns the abort count seen just before this
1046        // shutdown aborted the remaining tasks.
1047        let tracked_shutdown = async {
1048            if self.inner.shutdown.run_until(self.inner.tracker.wait()).await.is_err() {
1049                let aborted_before_timeout = self.inner.aborted.load(Ordering::Acquire);
1050                self.abort_tracked_tasks();
1051                let _ = self.inner.shutdown.run_until(self.inner.tracker.wait()).await;
1052                Some(aborted_before_timeout)
1053            } else {
1054                None
1055            }
1056        };
1057
1058        let (child_reports, aborted_before_timeout) = tokio::join!(child_reports, tracked_shutdown);
1059
1060        let mut report = ShutdownReport::new(self.inner.name.to_string(), started_at.elapsed());
1061        report.children = child_reports;
1062        self.record_task_outcomes(&mut report);
1063
1064        if let Some(aborted_before_timeout) = aborted_before_timeout {
1065            // Aborts requested earlier through `abort_task` are not timeouts.
1066            let aborted_by_timeout = report.aborted.saturating_sub(aborted_before_timeout);
1067            if aborted_by_timeout > 0 {
1068                report.annotations.push(ShutdownAnnotation::new(format!(
1069                    "confirmed cancellation of {aborted_by_timeout} aborted tasks"
1070                )));
1071            }
1072            report.timed_out = aborted_by_timeout + report.leaked;
1073        }
1074
1075        self.inner.lifecycle.store(STATE_SHUTDOWN_COMPLETED, Ordering::Release);
1076        report
1077    }
1078
1079    fn close_admission_tree(&self) -> Vec<TaskGroup> {
1080        let mut retained = Vec::new();
1081        let mut pending = vec![self.clone()];
1082        while let Some(group) = pending.pop() {
1083            {
1084                let _spawn_guard = group.inner.spawn_gate.lock();
1085                // Admission is sealed under the spawn gate. `Closed` follows
1086                // once cancellation has reached the whole tree.
1087                let _ = group
1088                    .inner
1089                    .lifecycle
1090                    .fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| {
1091                        matches!(state, STATE_OPEN | STATE_POISONED).then_some(STATE_CLOSING)
1092                    });
1093                group.inner.tracker.close();
1094                pending.extend(group.inner.registry.components_snapshot());
1095            }
1096            if group.id() != self.id() {
1097                retained.push(group);
1098            }
1099        }
1100        // Descendants precede parents for immediate report assembly.
1101        retained.reverse();
1102        retained
1103    }
1104
1105    /// Moves the groups closed by this shutdown from `Closing` to `Closed`.
1106    ///
1107    /// Called after cancellation was broadcast, so a group observed as
1108    /// `Closed` has a cancelled token. Readers already see `Closed` once the
1109    /// token is cancelled; this stores the state they derive.
1110    fn mark_closed(&self, descendants: &[TaskGroup]) {
1111        for group in descendants.iter().chain(std::iter::once(self)) {
1112            let _ = group.inner.lifecycle.compare_exchange(
1113                STATE_CLOSING,
1114                STATE_CLOSED,
1115                Ordering::AcqRel,
1116                Ordering::Acquire,
1117            );
1118        }
1119    }
1120
1121    fn shutdown_now_inner(&self) -> ShutdownReport {
1122        let started_at = Instant::now();
1123        let descendants = self.close_admission_tree();
1124        self.inner.cancellation_token.cancel();
1125        self.mark_closed(&descendants);
1126        let mut reports = std::collections::HashMap::new();
1127        for group in &descendants {
1128            let children = group
1129                .inner
1130                .registry
1131                .components_snapshot()
1132                .into_iter()
1133                .filter_map(|child| reports.remove(&child.id()))
1134                .collect();
1135            let report = if let Some(report) = group.inner.shutdown.report.get() {
1136                report.clone()
1137            } else {
1138                let report = group.shutdown_now_one(children);
1139                let _ = group.inner.shutdown.report.set(report.clone());
1140                group.inner.shutdown.report_ready.notify_waiters();
1141                report
1142            };
1143            reports.insert(group.id(), report);
1144        }
1145        let children = self
1146            .inner
1147            .registry
1148            .components_snapshot()
1149            .into_iter()
1150            .filter_map(|child| reports.remove(&child.id()))
1151            .collect();
1152        let mut report = self.shutdown_now_one(children);
1153        report.elapsed = started_at.elapsed();
1154        report
1155    }
1156
1157    fn shutdown_now_one(&self, child_reports: Vec<ShutdownReport>) -> ShutdownReport {
1158        let started_at = Instant::now();
1159
1160        let aborted_before = self.inner.aborted.load(Ordering::Acquire);
1161        self.abort_tracked_tasks();
1162
1163        let mut report = ShutdownReport::new(self.inner.name.to_string(), started_at.elapsed());
1164        report.children = child_reports;
1165        self.record_task_outcomes(&mut report);
1166
1167        let aborted_now = report.aborted.saturating_sub(aborted_before);
1168        if aborted_now > 0 {
1169            report.annotations.push(ShutdownAnnotation::new(format!(
1170                "confirmed cancellation of {aborted_now} aborted tasks"
1171            )));
1172        }
1173
1174        self.inner.lifecycle.store(STATE_SHUTDOWN_COMPLETED, Ordering::Release);
1175        report
1176    }
1177
1178    fn abort_tracked_tasks(&self) {
1179        for mut entry in self.inner.registry.tasks.iter_mut() {
1180            entry.abort_requested = true;
1181            if let Some(abort_handle) = &entry.abort_handle {
1182                abort_handle.abort();
1183            }
1184        }
1185    }
1186
1187    fn record_task_outcomes(&self, report: &mut ShutdownReport) {
1188        // Snapshot counters and active records together: a concurrent finalizer
1189        // must not make a task disappear between these two observations.
1190        let _settlement = self.inner.settlement_gate.lock();
1191        report.completed = self.inner.completed.load(Ordering::Relaxed);
1192        report.cancelled = self.inner.cancelled.load(Ordering::Relaxed);
1193        report.panicked = self.inner.panicked.load(Ordering::Relaxed);
1194        report.aborted = self.inner.aborted.load(Ordering::Relaxed);
1195        let closed_components = self.inner.closed_component_requests.load(Ordering::Relaxed);
1196        if closed_components > 0 {
1197            report.annotations.push(ShutdownAnnotation::new(format!(
1198                "{closed_components} component requests after admission closed received closed groups"
1199            )));
1200        }
1201        let mut requested = 0;
1202        for entry in self.inner.registry.tasks.iter() {
1203            requested += usize::from(entry.abort_requested);
1204            report.leaked += 1;
1205            report.push_remaining_task(entry.snapshot(TaskState::Leaked));
1206        }
1207        if requested > 0 {
1208            report.annotations.push(ShutdownAnnotation::new(format!(
1209                "abort requested for {requested} tasks whose destruction is not yet confirmed"
1210            )));
1211        }
1212    }
1213}
1214
1215impl TaskGroupInner {
1216    fn new(
1217        id: TaskGroupId,
1218        parent_id: Option<TaskGroupId>,
1219        name: Arc<str>,
1220        runtime: RuntimeHandle,
1221        cancellation_token: CancellationToken,
1222        parent: Option<Arc<TaskGroupInner>>,
1223        tree: Arc<TaskGroupTreeState>,
1224    ) -> Self {
1225        Self {
1226            id,
1227            parent_id,
1228            name,
1229            runtime,
1230            cancellation_token,
1231            tracker: TaskTracker::new(),
1232            registry: Arc::new(ActiveTaskRegistry::new()),
1233            parent,
1234            tree,
1235            closed_component_requests: AtomicUsize::new(0),
1236            next_task_id: AtomicU64::new(1),
1237            completed: AtomicUsize::new(0),
1238            cancelled: AtomicUsize::new(0),
1239            aborted: AtomicUsize::new(0),
1240            panicked: AtomicUsize::new(0),
1241            lifecycle: AtomicU8::new(STATE_OPEN),
1242            spawn_gate: Mutex::new(()),
1243            settlement_gate: Mutex::new(()),
1244            shutdown: ShutdownCoordinator::new(),
1245        }
1246    }
1247
1248    fn lifecycle_state(&self) -> TaskGroupLifecycleState {
1249        match self.lifecycle.load(Ordering::Acquire) {
1250            STATE_OPEN => TaskGroupLifecycleState::Open,
1251            // Shutdown cancels the whole tree before `mark_closed` stores
1252            // `Closed`. Deriving `Closed` from the cancelled token closes that
1253            // window, so a task woken by the cancellation on another worker
1254            // never observes `Closing`.
1255            STATE_CLOSING if self.cancellation_token.is_cancelled() => TaskGroupLifecycleState::Closed,
1256            STATE_CLOSING => TaskGroupLifecycleState::Closing,
1257            STATE_CLOSED => TaskGroupLifecycleState::Closed,
1258            STATE_SHUTDOWN_COMPLETED => TaskGroupLifecycleState::ShutdownCompleted,
1259            _ => TaskGroupLifecycleState::Poisoned,
1260        }
1261    }
1262
1263    fn mark_poisoned_if_open(&self) {
1264        if self
1265            .lifecycle
1266            .compare_exchange(STATE_OPEN, STATE_POISONED, Ordering::AcqRel, Ordering::Acquire)
1267            .is_ok()
1268        {
1269            self.tree.poisoned_groups.fetch_add(1, Ordering::Relaxed);
1270            tracing::error!(
1271                group = %self.path(),
1272                "task group poisoned by a panicking task; it no longer admits work"
1273            );
1274        }
1275    }
1276
1277    /// Returns the names from the root to this group, separated by `/`.
1278    fn path(&self) -> String {
1279        let mut names = vec![self.name.as_ref()];
1280        let mut parent = self.parent.as_deref();
1281        while let Some(group) = parent {
1282            names.push(group.name.as_ref());
1283            parent = group.parent.as_deref();
1284        }
1285        names.reverse();
1286        names.join("/")
1287    }
1288
1289    fn finish_task(&self, task_id: TaskId, result: TaskResult) {
1290        let _settlement = self.settlement_gate.lock();
1291        let Entry::Occupied(entry) = self.registry.tasks.entry(task_id) else {
1292            return;
1293        };
1294
1295        match result {
1296            TaskResult::Completed => {
1297                self.completed.fetch_add(1, Ordering::Relaxed);
1298            }
1299            TaskResult::Cancelled => {
1300                self.cancelled.fetch_add(1, Ordering::Relaxed);
1301            }
1302            TaskResult::Panicked => {
1303                self.panicked.fetch_add(1, Ordering::Relaxed);
1304                self.mark_poisoned_if_open();
1305            }
1306            TaskResult::Aborted => {
1307                self.aborted.fetch_add(1, Ordering::Relaxed);
1308            }
1309        }
1310        // A missing record is also treated as finished by wait_task. Publish
1311        // the counters before removing it, after user resources were dropped.
1312        self.registry.remove_task_detail(task_id);
1313        entry.remove();
1314    }
1315}
1316
1317impl Drop for TaskGroupInner {
1318    fn drop(&mut self) {
1319        let mut parent = self.parent.take();
1320        let mut child_id = self.id;
1321        while let Some(ancestor) = parent {
1322            ancestor.registry.unregister_component(child_id);
1323            let Some(mut ancestor) = Arc::into_inner(ancestor) else {
1324                break;
1325            };
1326            child_id = ancestor.id;
1327            parent = ancestor.parent.take();
1328            // Its parent is now detached, so dropping this owned node does
1329            // not recursively destroy the rest of a long ownership chain.
1330        }
1331    }
1332}
1333
1334impl TaskMeta {
1335    fn snapshot(&self, override_state: TaskState) -> TaskSnapshot {
1336        TaskSnapshot {
1337            id: self.id,
1338            name: self.name.to_string(),
1339            group_id: self.group_id,
1340            group_name: self.group_name.to_string(),
1341            kind: self.kind,
1342            state: override_state,
1343            elapsed: self.started_at.elapsed(),
1344        }
1345    }
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350    use super::*;
1351    use crate::RuntimeContext;
1352
1353    #[tokio::test]
1354    async fn a_closing_group_reports_closed_as_soon_as_its_token_is_cancelled() {
1355        let context = RuntimeContext::from_current("closing-then-cancelled");
1356        let group = context.service_context("owner").task_group().clone();
1357        let child = group.try_child("child").unwrap();
1358
1359        // Replay the shutdown steps one at a time, stopping in the window
1360        // between the cancellation broadcast and `mark_closed`.
1361        let descendants = group.close_admission_tree();
1362        assert_eq!(group.lifecycle_state(), TaskGroupLifecycleState::Closing);
1363        assert_eq!(child.lifecycle_state(), TaskGroupLifecycleState::Closing);
1364        group.inner.cancellation_token.cancel();
1365        assert_eq!(group.lifecycle_state(), TaskGroupLifecycleState::Closed);
1366        assert_eq!(child.lifecycle_state(), TaskGroupLifecycleState::Closed);
1367
1368        group.mark_closed(&descendants);
1369        assert_eq!(group.lifecycle_state(), TaskGroupLifecycleState::Closed);
1370        assert!(group.shutdown(Duration::from_secs(1)).await.is_healthy());
1371        assert_eq!(group.lifecycle_state(), TaskGroupLifecycleState::ShutdownCompleted);
1372    }
1373}